Skip to main content

lean_ctx_server/tools/registered/
ctx_search.rs

1// SPDX-License-Identifier: Apache-2.0
2use rmcp::ErrorData;
3use rmcp::model::Tool;
4use serde_json::{Map, Value, json};
5
6use crate::server::tool_trait::{
7    McpTool, ToolContext, ToolOutput, get_bool, get_int, get_str, get_str_array, get_usize,
8};
9use lean_ctx::core::ocla::cache_types::{CacheKeyBuilder, SearchQueryKey};
10use lean_ctx::tool_defs::tool_def;
11
12pub struct CtxSearchTool;
13
14/// Which search engine a `ctx_search` call routes to (#509). One tool, many
15/// engines — replacing the former `ctx_search`/`ctx_semantic_search`/`ctx_symbol`
16/// trio with a single, less ambiguous entry point.
17///
18/// Every variant is read-only (#1624). `reindex` used to live here and was the
19/// single reason `ctx_search` could not carry `readOnlyHint`, which locked the
20/// whole tool out of read-only client modes; it now belongs to `ctx_index`.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22enum SearchAction {
23    Regex,
24    Semantic,
25    Symbol,
26    /// Accepted only to answer with the replacement call — never executed.
27    MovedReindex,
28    FindRelated,
29}
30
31impl SearchAction {
32    /// An explicit `action` wins; otherwise the engine is inferred from which
33    /// field the caller set, so pre-#509 call sites (`pattern`/`query`/`name`)
34    /// keep working unchanged. Unknown `action` values fall through to inference.
35    fn resolve(args: &Map<String, Value>) -> Self {
36        if let Some(a) = get_str(args, "action") {
37            match a.trim().to_ascii_lowercase().as_str() {
38                "regex" | "grep" | "pattern" => return Self::Regex,
39                "semantic" | "search" => return Self::Semantic,
40                "symbol" => return Self::Symbol,
41                "reindex" => return Self::MovedReindex,
42                "find_related" | "related" => return Self::FindRelated,
43                _ => {}
44            }
45        }
46        if args.contains_key("handle") {
47            Self::Symbol
48        } else if args.contains_key("pattern") {
49            Self::Regex
50        } else if args.contains_key("name") {
51            Self::Symbol
52        } else if args.contains_key("file_path") && args.contains_key("line") {
53            Self::FindRelated
54        } else if args.contains_key("query") {
55            Self::Semantic
56        } else {
57            Self::Regex
58        }
59    }
60}
61
62/// Share action aliases/inference with pre-dispatch planning and response caching.
63pub(crate) fn uses_kernel_context(args: &Map<String, Value>) -> bool {
64    SearchAction::resolve(args) == SearchAction::Semantic
65}
66
67impl McpTool for CtxSearchTool {
68    fn name(&self) -> &'static str {
69        "ctx_search"
70    }
71
72    fn tool_def(&self) -> Tool {
73        tool_def(
74            "ctx_search",
75            "Search code: regex(pattern, default) | semantic(query) | symbol(name|handle) | \
76             find_related(file_path,line). Read-only. anchored=true enables ctx_patch refs; \
77             queries batches regex searches. Run ctx_compose FIRST.",
78            json!({
79                "type": "object",
80                "properties": {
81                    "action": {
82                        "type": "string",
83                        "enum": ["regex", "semantic", "symbol", "find_related"]
84                    },
85                    "pattern": { "type": "string" },
86                    "query": { "type": "string" },
87                    "name": { "type": "string" },
88                    "handle": { "type": "string" },
89                    "path": { "type": "string" },
90                    "paths": { "type": "array", "items": { "type": "string" } },
91                    "include": { "type": "string", "description": "Glob, e.g. *.rs" },
92                    "exclude": { "type": "string" },
93                    "exclude_pattern": { "type": "string" },
94                    "anchored": { "type": "boolean" },
95                    "max_results": {
96                        "type": "integer",
97                        "description": "shared across queries"
98                    },
99                    "top_k": { "type": "integer" },
100                    "mode": { "type": "string", "enum": ["bm25", "dense", "hybrid"] },
101                    "file": { "type": "string" },
102                    "kind": { "type": "string" },
103                    "file_path": { "type": "string" },
104                    "line": { "type": "integer" },
105                    "queries": {
106                        "type": "array",
107                        "items": { "type": "object" }
108                    }
109                },
110                "allOf": [
111                    {
112                        "if": { "properties": { "action": { "const": "regex" } }, "required": ["action"] },
113                        "then": { "anyOf": [{ "required": ["pattern"] }, { "required": ["queries"] }] }
114                    },
115                    {
116                        "if": { "properties": { "action": { "const": "semantic" } }, "required": ["action"] },
117                        "then": { "required": ["query"] }
118                    },
119                    {
120                        "if": { "properties": { "action": { "const": "symbol" } }, "required": ["action"] },
121                        "then": { "anyOf": [{ "required": ["name"] }, { "required": ["handle"] }] }
122                    }
123                ]
124            }),
125        )
126    }
127
128    fn handle(
129        &self,
130        args: &Map<String, Value>,
131        ctx: &ToolContext,
132    ) -> Result<ToolOutput, ErrorData> {
133        let action = SearchAction::resolve(args);
134        let result = match action {
135            SearchAction::Regex => handle_regex(args, ctx),
136            SearchAction::Semantic => handle_semantic(args, ctx),
137            SearchAction::Symbol => handle_symbol(args, ctx),
138            SearchAction::MovedReindex => Err(moved_reindex_error()),
139            SearchAction::FindRelated => handle_find_related(args, ctx),
140        };
141        if let Ok(output) = &result {
142            record_attribution_result(ctx, action, args, output);
143        }
144        result
145    }
146
147    /// `action=regex` only. `tools::ctx_search::handle_filtered` reads scanned
148    /// files outside the capture and then publishes exactly the files that
149    /// contributed a returned match (enclosing-symbol tags come from the same
150    /// file). Three results name no source and therefore stay of unknown
151    /// lineage rather than being claimed: a result without matches, a
152    /// delivery stub for a repeated query (`cached_or_search`), and an error.
153    ///
154    /// Not declared: `semantic` and `find_related` (ranked from the BM25/dense
155    /// index and the graph — derived stores without source lineage) and
156    /// `symbol` (the symbol index decides which file is read).
157    fn publishes_every_source(&self, args: Option<&Map<String, Value>>) -> bool {
158        args.is_some_and(|args| matches!(SearchAction::resolve(args), SearchAction::Regex))
159    }
160}
161
162fn record_attribution_result(
163    ctx: &ToolContext,
164    action: SearchAction,
165    args: &Map<String, Value>,
166    output: &ToolOutput,
167) {
168    let session_id = lean_ctx::core::task_spine::TaskSpine::task_id()
169        .or_else(|| {
170            ctx.session
171                .as_ref()
172                .and_then(|session| session.try_read().ok().map(|state| state.id.clone()))
173        })
174        .unwrap_or_else(|| "mcp-session".to_string());
175    let action = match action {
176        SearchAction::Regex => "regex",
177        SearchAction::Semantic => "semantic",
178        SearchAction::Symbol => "symbol",
179        SearchAction::MovedReindex => "reindex",
180        SearchAction::FindRelated => "find_related",
181    };
182    let query = get_str(args, "pattern")
183        .or_else(|| get_str(args, "query"))
184        .or_else(|| get_str(args, "handle"))
185        .or_else(|| get_str(args, "name"))
186        .or_else(|| get_str(args, "file_path"))
187        .unwrap_or_default();
188    let source = if query.is_empty() {
189        format!("ctx_search {action}")
190    } else {
191        format!("ctx_search {action} {query}")
192    };
193    let turn_provided = ctx.call_count.as_ref().map_or(0, |count| {
194        count.load(std::sync::atomic::Ordering::Relaxed) as u64
195    });
196    let token_cost = lean_ctx::core::tokens::count_tokens(&output.text);
197    let chunk = lean_ctx::core::causal_attribution::ContextChunkRecord::new(
198        &output.text,
199        source,
200        token_cost,
201        turn_provided,
202    );
203    if let Err(error) = lean_ctx::core::causal_attribution::record_chunk(&session_id, chunk) {
204        tracing::debug!(%error, "causal attribution ctx_search recording failed");
205    }
206}
207
208/// Known argument keys for ctx_search — used by the lenient fallback to detect
209/// unrecognized keys that weaker models may use instead of `pattern`.
210const KNOWN_KEYS: &[&str] = &[
211    "action",
212    "pattern",
213    "query",
214    "name",
215    "handle",
216    "path",
217    "paths",
218    "include",
219    "exclude",
220    "exclude_pattern",
221    "ext",
222    "anchored",
223    "max_results",
224    "top_k",
225    "mode",
226    "file",
227    "kind",
228    "file_path",
229    "line",
230    "languages",
231    "path_glob",
232    "workspace",
233    "artifacts",
234    "ignore_gitignore",
235];
236
237/// `action=regex` (default) — exact-pattern search over one or more roots.
238fn handle_regex(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
239    // #871: batch mode — `queries: [{pattern, include?, exclude?}]` runs multiple
240    // searches in one round-trip with grouped output.
241    if let Some(Value::Array(queries)) = args.get("queries") {
242        return handle_batch_queries(queries, args, ctx);
243    }
244    // Lenient fallback: if `pattern` is missing, accept the first unrecognized
245    // string value as the pattern. Handles weak models that use keys like
246    // "search_term", "text", "regex", etc. instead of the documented "pattern".
247    let pattern = get_str(args, "pattern")
248        .or_else(|| {
249            args.iter()
250                .find(|(k, v)| !KNOWN_KEYS.contains(&k.as_str()) && v.is_string())
251                .and_then(|(_, v)| v.as_str().map(String::from))
252        })
253        .ok_or_else(|| {
254            ErrorData::invalid_params(
255                "pattern is required. Example: ctx_search(pattern=\"fn main\", path=\"/src\")",
256                None,
257            )
258        })?;
259    let resolved = crate::server::multi_path::resolve_tool_paths(args, ctx)
260        .map_err(|e| ErrorData::invalid_params(format!("ERROR: {e}"), None))?;
261    // `include` is the canonical glob filter; `ext` is the deprecated alias
262    // (bare extension → `*.{ext}`). `include` wins when both are supplied.
263    let include =
264        get_str(args, "include").or_else(|| get_str(args, "ext").map(|e| ext_to_include(&e)));
265    let max = (get_int(args, "max_results").unwrap_or(20) as usize).min(500);
266    let no_gitignore = get_bool(args, "ignore_gitignore").unwrap_or(false);
267    // #1008: opt-in N:hh line anchors on each hit for direct ctx_patch edits.
268    let anchored = get_bool(args, "anchored").unwrap_or(false);
269    // #870: negative filters — `exclude` (path glob, complement of `include`)
270    // and `exclude_pattern` (regex dropping matching result lines, grep -v).
271    let exclude = get_str(args, "exclude");
272    let exclude_pattern = get_str(args, "exclude_pattern");
273
274    if no_gitignore
275        && let Err(e) = lean_ctx::core::io_boundary::ensure_ignore_gitignore_allowed("ctx_search")
276    {
277        // A refusal, not a search result: the same text, as a tool error.
278        return Err(crate::server::tool_trait::tool_execution_error(e));
279    }
280
281    let crp = ctx.crp_mode;
282    let respect = !no_gitignore;
283    let allow_secret_paths = lean_ctx::core::roles::active_role().io.allow_secret_paths;
284
285    if !resolved.is_multi {
286        return search_single(
287            &pattern,
288            &resolved.roots[0],
289            include.as_deref(),
290            max,
291            crp,
292            respect,
293            allow_secret_paths,
294            anchored,
295            exclude.as_deref(),
296            exclude_pattern.as_deref(),
297        )
298        .map(|output| resolved.annotate(output));
299    }
300
301    let _mode_guard = lean_ctx::core::savings_footer::ModeGuard::new("search");
302    let per_root_max = (max / resolved.roots.len()).max(5);
303    let mut combined = String::new();
304    let mut total_observed: usize = 0;
305    let mut total_sent: usize = 0;
306    let mut remaining_budget = max;
307    // Roots whose search failed, and roots that answered (matches or "0
308    // matches"). A call in which every searched root failed is a failed call.
309    let mut failed_roots: usize = 0;
310    let mut answered_roots: usize = 0;
311
312    for root in &resolved.roots {
313        if remaining_budget == 0 {
314            break;
315        }
316        let this_root_max = per_root_max.min(remaining_budget);
317        let search_result = tokio::task::block_in_place(|| {
318            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
319                cached_or_search(
320                    &pattern,
321                    root,
322                    include.as_deref(),
323                    this_root_max,
324                    crp,
325                    respect,
326                    allow_secret_paths,
327                    anchored,
328                    exclude.as_deref(),
329                    exclude_pattern.as_deref(),
330                )
331            }))
332            .ok()
333        });
334
335        let Some(outcome) = search_result else {
336            combined.push_str(&format!("── {root} ──\nERROR: search panicked\n\n"));
337            failed_roots += 1;
338            continue;
339        };
340        note_secret_paths_withheld(outcome.secret_paths_withheld);
341        let failed = outcome.failed;
342        let result = outcome.text;
343
344        if result.trim().is_empty() {
345            continue;
346        }
347
348        combined.push_str(&format!("── {root} ──\n{result}\n\n"));
349
350        if failed {
351            failed_roots += 1;
352            continue;
353        }
354        answered_roots += 1;
355
356        let result_matches = result.lines().filter(|l| !l.is_empty()).count();
357        remaining_budget = remaining_budget.saturating_sub(result_matches);
358        total_observed += outcome.observed_tokens;
359        total_sent += lean_ctx::core::tokens::count_tokens(&result);
360    }
361
362    if combined.is_empty() {
363        combined = "No matches found across any root.".to_string();
364    }
365
366    // Dashboard, footer and verified ledger all use *observed* tokens —
367    // the modeled 2.5x native-grep baseline never inflates user-facing
368    // numbers (GL #573). It only feeds the explicitly-estimated stats
369    // series via `tool_lifecycle::record_search`.
370    let final_out = lean_ctx::core::protocol::append_savings(&combined, total_observed, total_sent);
371    if failed_roots > 0 && answered_roots == 0 {
372        // Nothing was searched successfully: the same text, as a tool error.
373        return Err(crate::server::tool_trait::tool_execution_error(final_out));
374    }
375    let saved = total_observed.saturating_sub(total_sent);
376    // #685: `actual_tokens` is the *sent* output, not the saving — passing
377    // `saved` here recorded `actual=observed−sent` and `saved=sent` (both
378    // wrong). Align with cli_grep/cli_shell, which pass the output count.
379    lean_ctx::core::savings_ledger::record_tool_event(
380        "ctx_search",
381        total_observed,
382        total_sent,
383        None,
384        None,
385    );
386
387    // R30: Search evidence for batch queries.
388    crate::tools::search_hook::on_search("batch_query", "regex", total_observed, total_sent);
389
390    Ok(ToolOutput {
391        text: final_out,
392        original_tokens: total_observed,
393        saved_tokens: saved,
394        mode: None,
395        path: None,
396        changed: false,
397        shell_outcome: None,
398        content_blocks: None,
399    })
400}
401
402/// Resolve the `path` arg to a jailed path, falling back to the project root —
403/// the same precedence the former standalone semantic-search tool used.
404fn resolve_path_or_root(ctx: &ToolContext) -> Result<String, ErrorData> {
405    if let Some(p) = ctx.resolved_path("path") {
406        Ok(p.to_string())
407    } else if let Some(err) = ctx.path_error("path") {
408        Err(ErrorData::invalid_params(format!("path: {err}"), None))
409    } else {
410        Ok(ctx.project_root.clone())
411    }
412}
413
414/// Prime the per-call BM25 cache so semantic engines reuse the warmed index
415/// instead of reloading it from disk (perf parity with the former tool).
416fn prime_bm25_cache(ctx: &ToolContext) {
417    if let Some(ref cache) = ctx.bm25_cache {
418        crate::tools::ctx_semantic_search::set_thread_cache(cache.clone());
419    }
420}
421
422/// `action=semantic` — meaning-based search, routed to the shared core fn.
423fn handle_semantic(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
424    let path = resolve_path_or_root(ctx)?;
425    lean_ctx::core::policy::runtime::with_project_source_view(&path, || {
426        handle_semantic_in_view(args, ctx)
427    })
428    .map_err(|_| {
429        ErrorData::internal_error("Search source authority changed or is unavailable", None)
430    })?
431}
432
433fn handle_semantic_in_view(
434    args: &Map<String, Value>,
435    ctx: &ToolContext,
436) -> Result<ToolOutput, ErrorData> {
437    let query = get_str(args, "query")
438        .ok_or_else(|| ErrorData::invalid_params("query is required for action=semantic", None))?;
439    let path = resolve_path_or_root(ctx)?;
440    let top_k = get_usize(args, "top_k").unwrap_or(10).min(1000);
441    let mode = get_str(args, "mode");
442    let languages = get_str_array(args, "languages");
443    let path_glob = get_str(args, "path_glob");
444    let workspace = get_bool(args, "workspace").unwrap_or(false);
445    let artifacts = get_bool(args, "artifacts").unwrap_or(false);
446    prime_bm25_cache(ctx);
447
448    let search_phase = lean_ctx::core::phase_timing::enter("ctx_search semantic: search");
449    let mut result = tokio::task::block_in_place(|| {
450        crate::tools::ctx_semantic_search::handle(
451            &query,
452            &path,
453            top_k,
454            ctx.crp_mode,
455            languages.as_deref(),
456            path_glob.as_deref(),
457            mode.as_deref(),
458            Some(workspace),
459            Some(artifacts),
460        )
461    });
462
463    drop(search_phase);
464
465    // Legacy cross-store blocks have no current original-source provenance.
466    // Keep them out of protected results, including explicit mode refusals.
467    if !lean_ctx::core::policy::runtime::is_active() && !result.starts_with("ERR:") {
468        let _phase = lean_ctx::core::phase_timing::enter("ctx_search semantic: kernel enrichment");
469        let kernel_budget = 100;
470        if let Some(enrichment) =
471            lean_ctx::core::context_kernel::bridge::kernel_enrich(&query, &path, kernel_budget)
472            && !enrichment.blocks.is_empty()
473        {
474            result.push_str("\n--- kernel context ---\n");
475            result.push_str(&enrichment.blocks);
476        }
477    }
478
479    // R30: Search evidence for semantic searches.
480    let search_tokens = lean_ctx::core::tokens::count_tokens(&result);
481    crate::tools::search_hook::on_search(&query, "semantic", search_tokens, search_tokens);
482    Ok(semantic_output(result))
483}
484
485/// #1108: when `path` or `file` is an absolute path under a different project,
486/// resolve that project's root for the graph lookup. Falls back to the session
487/// project_root when no cross-project path is given.
488fn resolve_symbol_root(args: &Map<String, Value>, session_root: &str) -> String {
489    let candidate = get_str(args, "path")
490        .or_else(|| get_str(args, "file"))
491        .filter(|p| std::path::Path::new(p.as_str()).is_absolute());
492
493    if let Some(abs_path) = candidate
494        && let Some(detected) = lean_ctx::core::protocol::detect_project_root(&abs_path)
495        && detected != session_root
496        // Under a Workspace only one of its roots may be looked into (#2120).
497        && lean_ctx::core::workspace_authority::root_authorized(std::path::Path::new(&detected))
498    {
499        return detected;
500    }
501    session_root.to_string()
502}
503
504/// `action=symbol` — one symbol's body. A `handle` (`path#name@Lline`) resolves
505/// deterministically (exact, no fuzzy/disambiguation); otherwise `name` runs the
506/// fuzzy lookup. Both route to the shared `ctx_symbol` core.
507fn handle_symbol(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
508    // #1108: resolve graph root from `path` when given, instead of always
509    // using the sticky session project_root. This enables cross-repo symbol
510    // lookup in multi-project MCP sessions.
511    let effective_root = resolve_symbol_root(args, &ctx.project_root);
512
513    if let Some(handle) = get_str(args, "handle") {
514        let (result, original) =
515            crate::tools::ctx_symbol::render_by_handle(&handle, &effective_root);
516        let sent = lean_ctx::core::tokens::count_tokens(&result);
517        return Ok(ToolOutput {
518            text: result,
519            original_tokens: original,
520            saved_tokens: original.saturating_sub(sent),
521            mode: Some("handle".to_string()),
522            path: None,
523            changed: false,
524            shell_outcome: None,
525            content_blocks: None,
526        });
527    }
528
529    // Agents that pick action=symbol often name the symbol in the field they
530    // used for the other actions; take it instead of failing the call.
531    let name = ["name", "symbol", "query", "pattern"]
532        .iter()
533        .find_map(|key| get_str(args, key).filter(|value| !value.trim().is_empty()))
534        .ok_or_else(|| {
535            ErrorData::invalid_params("name or handle is required for action=symbol", None)
536        })?;
537    let file = get_str(args, "file");
538    let kind = get_str(args, "kind");
539
540    let (result, original) =
541        crate::tools::ctx_symbol::handle(&name, file.as_deref(), kind.as_deref(), &effective_root);
542    let sent = lean_ctx::core::tokens::count_tokens(&result);
543    // R30: Search evidence for symbol lookups.
544    crate::tools::search_hook::on_search(&name, "symbol", original, sent);
545    Ok(ToolOutput {
546        text: result,
547        original_tokens: original,
548        saved_tokens: original.saturating_sub(sent),
549        mode: kind,
550        path: file,
551        changed: false,
552        shell_outcome: None,
553        content_blocks: None,
554    })
555}
556
557/// `action=reindex` was removed from `ctx_search` (#1624).
558///
559/// It was the one mutating path in an otherwise read-only tool, and MCP
560/// annotations are per *tool*, not per action — so its presence stripped
561/// `readOnlyHint` from `ctx_search` and made the whole tool unavailable in
562/// every read-only client mode (Devin Plan mode, Cursor's restricted contexts).
563/// Search was collateral damage for a rebuild action that `ctx_index` already
564/// owns and correctly advertises as mutating.
565///
566/// The call is answered rather than silently dropped: an agent that learned
567/// `action="reindex"` gets the exact replacement line, not "unknown action".
568fn moved_reindex_error() -> ErrorData {
569    ErrorData::invalid_params(
570        "ctx_search no longer performs reindex — it is read-only so it stays available \
571         in read-only/plan modes (#1624). Rebuild the index with \
572         ctx_index(action=\"build-full\"), or ctx_index(action=\"build\") for an \
573         incremental pass.",
574        None,
575    )
576}
577
578/// `action=find_related` — context neighbors for a source location, via core.
579fn handle_find_related(
580    args: &Map<String, Value>,
581    ctx: &ToolContext,
582) -> Result<ToolOutput, ErrorData> {
583    let path = resolve_path_or_root(ctx)?;
584    let top_k = get_usize(args, "top_k").unwrap_or(10).min(1000);
585    let fp = get_str(args, "file_path").unwrap_or_default();
586    let line = get_int(args, "line").unwrap_or(1) as usize;
587    if fp.is_empty() {
588        return Err(ErrorData::invalid_params(
589            "find_related requires file_path and line",
590            None,
591        ));
592    }
593    prime_bm25_cache(ctx);
594
595    let result = tokio::task::block_in_place(|| {
596        crate::tools::ctx_semantic_search::handle_find_related(
597            &fp,
598            line,
599            &path,
600            top_k,
601            ctx.crp_mode,
602        )
603    });
604
605    Ok(semantic_output(result))
606}
607
608/// Shared `ToolOutput` shape for the semantic-engine branches (token accounting
609/// is handled inside the core fns, mirroring the former standalone tool).
610fn semantic_output(text: String) -> ToolOutput {
611    let tokens = lean_ctx::core::tokens::count_tokens(&text);
612    ToolOutput {
613        text,
614        original_tokens: tokens,
615        saved_tokens: 0,
616        mode: Some("semantic".to_string()),
617        path: None,
618        changed: false,
619        shell_outcome: None,
620        content_blocks: None,
621    }
622}
623
624/// Notes the files the secret-path boundary kept out of this call's search:
625/// one `content_withheld:<n>|reason=secret_path` entry per call, never a path.
626fn note_secret_paths_withheld(n: usize) {
627    lean_ctx::core::security_events::note_refusals(
628        lean_ctx::core::security_events::RefusalClass::SecretPath,
629        n,
630    );
631}
632
633#[allow(clippy::too_many_arguments)]
634fn cached_or_search(
635    pattern: &str,
636    path: &str,
637    include: Option<&str>,
638    max: usize,
639    crp: crate::tools::CrpMode,
640    respect_gitignore: bool,
641    allow_secret_paths: bool,
642    anchored: bool,
643    exclude: Option<&str>,
644    exclude_pattern: Option<&str>,
645) -> crate::tools::ctx_search::SearchOutcome {
646    let builder = regex_cache_builder(
647        pattern,
648        path,
649        include,
650        exclude,
651        exclude_pattern,
652        max,
653        respect_gitignore,
654        allow_secret_paths,
655        anchored,
656    );
657    if let Some(builder) = &builder
658        && let Some(entry) = lean_ctx::core::ocla::cache_delivery::check(
659            &builder.cache_key(),
660            &builder.validator(),
661            "ctx_search",
662        )
663    {
664        let text = lean_ctx::core::ocla::cache_delivery::stub(&entry, "regex search");
665        return crate::tools::ctx_search::SearchOutcome {
666            text,
667            modeled_baseline: entry.token_count as usize,
668            observed_tokens: entry.token_count as usize,
669            failed: false,
670            // The stub delivers no scan: the first delivery recorded it.
671            secret_paths_withheld: 0,
672        };
673    }
674
675    let outcome = crate::tools::ctx_search::handle_filtered(
676        pattern,
677        path,
678        include,
679        max,
680        crp,
681        respect_gitignore,
682        allow_secret_paths,
683        anchored,
684        exclude,
685        exclude_pattern,
686    );
687    if !outcome.failed
688        && let Some(builder) = builder
689    {
690        lean_ctx::core::ocla::cache_delivery::record(
691            builder.cache_key(),
692            lean_ctx::core::ocla::cache_types::DeliveryKind::SearchQuery,
693            builder.validator(),
694            Some(builder.path),
695            &outcome.text,
696            "ctx_search",
697        );
698    }
699    outcome
700}
701
702#[allow(clippy::too_many_arguments)]
703fn regex_cache_builder(
704    pattern: &str,
705    path: &str,
706    include: Option<&str>,
707    exclude: Option<&str>,
708    exclude_pattern: Option<&str>,
709    max: usize,
710    respect_gitignore: bool,
711    allow_secret_paths: bool,
712    anchored: bool,
713) -> Option<SearchQueryKey> {
714    if lean_ctx::core::policy::runtime::active().is_some() {
715        return None;
716    }
717    let canonical = lean_ctx::core::pathutil::safe_canonicalize_or_self(std::path::Path::new(path));
718    // Directory mtime does not change when an existing source is rewritten.
719    // Reuse the index's bounded metadata walk; an uncheckable corpus is never
720    // eligible for a legacy delivery stub, including after policy removal.
721    let revision = lean_ctx::core::search_index::corpus_signature(
722        &canonical.to_string_lossy(),
723        respect_gitignore,
724        allow_secret_paths,
725    )?;
726    Some(SearchQueryKey {
727        pattern: pattern.into(),
728        include: format!(
729            "{}\\x1fmax:{max}\\x1fgitignore:{respect_gitignore}\\x1fsecret:{allow_secret_paths}\\x1fanchored:{anchored}",
730            include.unwrap_or_default()
731        ),
732        exclude: format!(
733            "{}\\x1fline:{}",
734            exclude.unwrap_or_default(),
735            exclude_pattern.unwrap_or_default()
736        ),
737        path: canonical.to_string_lossy().into_owned(),
738        index_rev: revision.to_string(),
739    })
740}
741
742#[cfg(test)]
743mod cache_delivery_tests {
744    use super::*;
745
746    #[test]
747    fn regex_adapter_records_then_serves_a_cross_agent_reference() {
748        let _policy = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
749        let directory = tempfile::tempdir().unwrap();
750        std::fs::write(
751            directory.path().join("cached.rs"),
752            "fn cache_delivery_probe() {}\n",
753        )
754        .unwrap();
755        let path = directory.path().to_string_lossy();
756
757        let first = search_single(
758            "cache_delivery_probe",
759            &path,
760            Some("*.rs"),
761            20,
762            crate::tools::CrpMode::Off,
763            true,
764            true,
765            false,
766            None,
767            None,
768        )
769        .unwrap();
770        assert!(first.text.contains("cache_delivery_probe"));
771        let second = search_single(
772            "cache_delivery_probe",
773            &path,
774            Some("*.rs"),
775            20,
776            crate::tools::CrpMode::Off,
777            true,
778            true,
779            false,
780            None,
781            None,
782        )
783        .unwrap();
784        assert!(
785            second.text.contains("[cross-agent cache"),
786            "{}",
787            second.text
788        );
789        // Same path, changed contents: the directory mtime alone misses this.
790        std::fs::write(
791            directory.path().join("cached.rs"),
792            "fn cache_delivery_probe_updated() {}\n",
793        )
794        .unwrap();
795        let updated = search_single(
796            "cache_delivery_probe",
797            &path,
798            Some("*.rs"),
799            20,
800            crate::tools::CrpMode::Off,
801            true,
802            true,
803            false,
804            None,
805            None,
806        )
807        .unwrap();
808        assert!(updated.text.contains("cache_delivery_probe_updated"));
809        assert!(!updated.text.contains("[cross-agent cache"));
810    }
811}
812
813#[allow(clippy::too_many_arguments)]
814fn search_single(
815    pattern: &str,
816    path: &str,
817    include: Option<&str>,
818    max: usize,
819    crp: crate::tools::CrpMode,
820    respect_gitignore: bool,
821    allow_secret_paths: bool,
822    anchored: bool,
823    exclude: Option<&str>,
824    exclude_pattern: Option<&str>,
825) -> Result<ToolOutput, ErrorData> {
826    let _mode_guard = lean_ctx::core::savings_footer::ModeGuard::new("search");
827
828    let search_result = tokio::task::block_in_place(|| {
829        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
830            cached_or_search(
831                pattern,
832                path,
833                include,
834                max,
835                crp,
836                respect_gitignore,
837                allow_secret_paths,
838                anchored,
839                exclude,
840                exclude_pattern,
841            )
842        }));
843        match result {
844            Ok(r) => Ok(r),
845            Err(_) => Err("search task panicked"),
846        }
847    });
848
849    let outcome = match search_result {
850        Ok(r) => r,
851        Err(e) => {
852            return Err(ErrorData::internal_error(
853                format!("search task failed: {e}"),
854                None,
855            ));
856        }
857    };
858    note_secret_paths_withheld(outcome.secret_paths_withheld);
859    let failed = outcome.failed;
860    let result = outcome.text;
861    // Observed tokens only — the modeled native-grep baseline stays out of
862    // dashboard/footer/ledger (GL #573); see the multi-root branch above.
863    let observed = outcome.observed_tokens;
864
865    // The search's own typed failure (`SearchOutcome::failed`), not the
866    // wording of its text: a result that begins with "ERROR" is a result.
867    if failed {
868        return Err(ErrorData::invalid_params(result, None));
869    }
870
871    let sent = lean_ctx::core::tokens::count_tokens(&result);
872    let saved = observed.saturating_sub(sent);
873    let final_out = lean_ctx::core::protocol::append_savings(&result, observed, sent);
874    // #685: pass the *sent* output as `actual_tokens` (not `saved`); see the
875    // multi-root branch above for why the previous arg was a double bug.
876    lean_ctx::core::savings_ledger::record_tool_event("ctx_search", observed, sent, None, None);
877
878    // R30: Search evidence + dedup detection via kernel.
879    crate::tools::search_hook::on_search(pattern, "regex", observed, sent);
880
881    Ok(ToolOutput {
882        text: final_out,
883        original_tokens: observed,
884        saved_tokens: saved,
885        mode: None,
886        path: Some(path.to_string()),
887        changed: false,
888        shell_outcome: None,
889        content_blocks: None,
890    })
891}
892
893/// Translate the deprecated `ext` parameter into an `include` glob.
894///
895/// The historical `ext` accepted a bare extension (`rs` or `.rs`) and matched it
896/// exactly; the equivalent glob is `*.{ext}` (the `glob` crate's `*` spans path
897/// separators, so it still matches at any depth, preserving the old behaviour).
898/// A value that already looks like a glob/path (`*`, `{`, `?`, `/`) is passed
899/// through untouched so any power user who put a pattern in `ext` keeps working.
900/// Keys a `queries[]` entry may carry. `items` used to be an untyped object,
901/// so a misspelled or unsupported key was dropped without a word — #1625 lost
902/// half an audit's results to exactly that.
903/// `regex`/`query` and `glob` are the spellings other search tools use for
904/// `pattern` and `include`; they mean exactly that here.
905const QUERY_KEYS: &[&str] = &[
906    "pattern",
907    "include",
908    "ext",
909    "exclude",
910    "exclude_pattern",
911    "max_results",
912    "path",
913    "file_path",
914];
915
916/// Near-miss spellings agents guess for a query key, mapped to the canonical
917/// one. Each rejection cost a whole extra request (focus 2026-10-15: 259
918/// failures across 74 installations); a key whose meaning is unambiguous is
919/// accepted instead. The canonical key wins when both are present.
920const QUERY_KEY_ALIASES: &[(&str, &str)] = &[
921    ("regex", "pattern"),
922    ("query", "pattern"),
923    ("maxResults", "max_results"),
924    ("limit", "max_results"),
925    ("glob", "include"),
926    ("filePattern", "include"),
927    ("file_pattern", "include"),
928    ("excludePattern", "exclude_pattern"),
929    ("filePath", "file_path"),
930];
931
932/// Canonicalize a query entry's keys and reject any key `handle_batch_queries`
933/// would ignore.
934fn normalize_query_keys(
935    obj: &Map<String, Value>,
936    idx: usize,
937) -> Result<Map<String, Value>, String> {
938    let mut out = Map::with_capacity(obj.len());
939    for (key, value) in obj {
940        let canonical = if QUERY_KEYS.contains(&key.as_str()) {
941            key.as_str()
942        } else if let Some((_, canonical)) =
943            QUERY_KEY_ALIASES.iter().find(|(alias, _)| *alias == key)
944        {
945            if obj.contains_key(*canonical) {
946                continue;
947            }
948            canonical
949        } else {
950            return Err(format!(
951                "queries[{}]: unknown key '{key}' — accepted keys are {}",
952                idx + 1,
953                QUERY_KEYS.join(", ")
954            ));
955        };
956        out.insert(canonical.to_string(), value.clone());
957    }
958    Ok(out)
959}
960
961/// The cap for one query: its own `max_results` when present, otherwise its
962/// equal share of the shared top-level budget. Clamped to the same 500 ceiling
963/// the top-level value gets, so a per-query value cannot escape the global one.
964fn resolve_query_cap(
965    obj: &Map<String, Value>,
966    default_share: usize,
967    idx: usize,
968) -> Result<usize, String> {
969    match get_int(obj, "max_results") {
970        Some(n) if n > 0 => Ok((n as usize).min(500)),
971        Some(_) => Err(format!(
972            "queries[{}].max_results must be a positive integer",
973            idx + 1
974        )),
975        None => Ok(default_share),
976    }
977}
978
979/// #871: batch multi-query — runs each query independently and groups output.
980fn handle_batch_queries(
981    queries: &[Value],
982    args: &Map<String, Value>,
983    ctx: &ToolContext,
984) -> Result<ToolOutput, ErrorData> {
985    if queries.is_empty() {
986        return Err(ErrorData::invalid_params(
987            "queries array must not be empty",
988            None,
989        ));
990    }
991    if queries.len() > 10 {
992        return Err(ErrorData::invalid_params(
993            "queries array limited to 10 entries",
994            None,
995        ));
996    }
997
998    let resolved = crate::server::multi_path::resolve_tool_paths(args, ctx)
999        .map_err(|e| ErrorData::invalid_params(format!("ERROR: {e}"), None))?;
1000    let no_gitignore = get_bool(args, "ignore_gitignore").unwrap_or(false);
1001    let anchored = get_bool(args, "anchored").unwrap_or(false);
1002    let crp = ctx.crp_mode;
1003    let respect = !no_gitignore;
1004    let allow_secret_paths = lean_ctx::core::roles::active_role().io.allow_secret_paths;
1005    let root = &resolved.roots[0];
1006    let global_max = (get_int(args, "max_results").unwrap_or(20) as usize).min(500);
1007    // #1625: the top-level budget is *shared* — it is split across the queries,
1008    // so two queries under the default 20 get 10 each. That is a defensible
1009    // default, but a `max_results` written inside a query object used to be
1010    // dropped on the floor, and `queries.items` was an untyped object so no
1011    // validation error came back either. A per-query value now wins over its
1012    // share of the split, and anything unrecognised is rejected rather than
1013    // silently ignored.
1014    let default_share = (global_max / queries.len()).max(5);
1015
1016    let _mode_guard = lean_ctx::core::savings_footer::ModeGuard::new("search");
1017    let mut combined = String::new();
1018    let mut total_observed: usize = 0;
1019    let mut total_sent: usize = 0;
1020    // Queries that failed, and queries that answered (matches or "0
1021    // matches"). A call in which every query failed is a failed call.
1022    let mut failed_queries: usize = 0;
1023    let mut answered_queries: usize = 0;
1024    // Queries usually share one root and so the same withheld files: the
1025    // largest count is recorded, never the sum.
1026    let mut secret_paths_withheld: usize = 0;
1027
1028    for (idx, q) in queries.iter().enumerate() {
1029        let Some(obj) = q.as_object() else {
1030            combined.push_str(&format!(
1031                "── query {} ──\nERROR: expected object\n\n",
1032                idx + 1
1033            ));
1034            failed_queries += 1;
1035            continue;
1036        };
1037        // #1625: an untyped `items` schema meant a misspelled or unsupported
1038        // key vanished without a word — the reporter lost half their results to
1039        // exactly that. Near-misses are canonicalized; anything else is named.
1040        let obj =
1041            &normalize_query_keys(obj, idx).map_err(|e| ErrorData::invalid_params(e, None))?;
1042        let Some(pattern) = get_str(obj, "pattern") else {
1043            combined.push_str(&format!(
1044                "── query {} ──\nERROR: pattern required\n\n",
1045                idx + 1
1046            ));
1047            failed_queries += 1;
1048            continue;
1049        };
1050        let include =
1051            get_str(obj, "include").or_else(|| get_str(obj, "ext").map(|e| ext_to_include(&e)));
1052        let exclude = get_str(obj, "exclude");
1053        let exclude_pattern = get_str(obj, "exclude_pattern");
1054        let per_query_max = resolve_query_cap(obj, default_share, idx)
1055            .map_err(|e| ErrorData::invalid_params(e, None))?;
1056        // A per-query `path` scopes that query only; the top-level path (or
1057        // the project) stays the default for the others.
1058        let query_root = match get_str(obj, "path").or_else(|| get_str(obj, "file_path")) {
1059            Some(raw) => match ctx.resolve_path_sync(&raw) {
1060                Ok(p) => p,
1061                Err(e) => {
1062                    combined.push_str(&format!(
1063                        "── query {}: '{}' ──\nERROR: {e}\n\n",
1064                        idx + 1,
1065                        truncate_query(&pattern, 40)
1066                    ));
1067                    continue;
1068                }
1069            },
1070            None => root.clone(),
1071        };
1072
1073        let search_result = tokio::task::block_in_place(|| {
1074            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1075                cached_or_search(
1076                    &pattern,
1077                    &query_root,
1078                    include.as_deref(),
1079                    per_query_max,
1080                    crp,
1081                    respect,
1082                    allow_secret_paths,
1083                    anchored,
1084                    exclude.as_deref(),
1085                    exclude_pattern.as_deref(),
1086                )
1087            }))
1088            .ok()
1089        });
1090
1091        let label = if queries.len() > 1 {
1092            format!(
1093                "── query {}: '{}' ──\n",
1094                idx + 1,
1095                truncate_query(&pattern, 40)
1096            )
1097        } else {
1098            String::new()
1099        };
1100
1101        let Some(outcome) = search_result else {
1102            combined.push_str(&format!("{label}ERROR: search panicked\n\n"));
1103            failed_queries += 1;
1104            continue;
1105        };
1106
1107        secret_paths_withheld = secret_paths_withheld.max(outcome.secret_paths_withheld);
1108        if outcome.failed {
1109            failed_queries += 1;
1110        } else {
1111            answered_queries += 1;
1112        }
1113        if !outcome.text.trim().is_empty() {
1114            combined.push_str(&format!("{label}{}\n\n", outcome.text));
1115            total_observed += outcome.observed_tokens;
1116            total_sent += lean_ctx::core::tokens::count_tokens(&outcome.text);
1117        }
1118    }
1119
1120    note_secret_paths_withheld(secret_paths_withheld);
1121    if combined.is_empty() {
1122        combined = "No matches found for any query.".to_string();
1123    }
1124
1125    let final_out = lean_ctx::core::protocol::append_savings(&combined, total_observed, total_sent);
1126    if failed_queries > 0 && answered_queries == 0 {
1127        // No query was searched successfully: the same text, as a tool error.
1128        return Err(crate::server::tool_trait::tool_execution_error(final_out));
1129    }
1130    let saved = total_observed.saturating_sub(total_sent);
1131    lean_ctx::core::savings_ledger::record_tool_event(
1132        "ctx_search",
1133        total_observed,
1134        total_sent,
1135        None,
1136        None,
1137    );
1138
1139    // R30: Search evidence for batch queries.
1140    crate::tools::search_hook::on_search("batch_query", "regex", total_observed, total_sent);
1141
1142    Ok(resolved.annotate(ToolOutput {
1143        text: final_out,
1144        original_tokens: total_observed,
1145        saved_tokens: saved,
1146        mode: None,
1147        path: None,
1148        changed: false,
1149        shell_outcome: None,
1150        content_blocks: None,
1151    }))
1152}
1153
1154/// Truncate a query string for display (used in batch labels).
1155fn truncate_query(q: &str, max: usize) -> String {
1156    if q.len() <= max {
1157        q.to_string()
1158    } else {
1159        format!("{}...", &q[..q.floor_char_boundary(max)])
1160    }
1161}
1162
1163fn ext_to_include(ext: &str) -> String {
1164    if ext.contains(['*', '{', '?', '/']) {
1165        return ext.to_string();
1166    }
1167    let bare = ext.strip_prefix('.').unwrap_or(ext);
1168    format!("*.{bare}")
1169}
1170
1171#[cfg(test)]
1172#[path = "ctx_search_tests.rs"]
1173mod tests;
1174
1175#[cfg(test)]
1176#[path = "ctx_search_mcp_tests.rs"]
1177mod mcp_tests;