Skip to main content

tandem_tools/lib_parts/
part01.rs

1use std::collections::{hash_map::DefaultHasher, HashMap, HashSet};
2use std::hash::{Hash, Hasher};
3use std::path::{Path, PathBuf};
4use std::process::Stdio;
5use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering as AtomicOrdering};
6use std::sync::{Arc, Mutex};
7use std::time::Duration;
8
9use anyhow::anyhow;
10use async_trait::async_trait;
11use grep_matcher::LineTerminator;
12use grep_regex::{RegexMatcher, RegexMatcherBuilder};
13use grep_searcher::sinks::Lossy;
14use grep_searcher::{BinaryDetection, MmapChoice, SearcherBuilder};
15use ignore::{ParallelVisitor, ParallelVisitorBuilder, WalkBuilder, WalkState};
16use regex::Regex;
17use serde_json::{json, Value};
18use tandem_memory::embeddings::{get_embedding_service, EmbeddingService};
19use tandem_skills::SkillService;
20use tokio::fs;
21use tokio::process::Command;
22use tokio::sync::RwLock;
23use tokio_util::sync::CancellationToken;
24
25use futures_util::StreamExt;
26use tandem_agent_teams::compat::{
27    send_message_schema, task_create_schema, task_list_schema, task_schema, task_update_schema,
28    team_create_schema,
29};
30use tandem_agent_teams::{
31    AgentTeamPaths, SendMessageInput, SendMessageType, TaskCreateInput, TaskInput, TaskListInput,
32    TaskUpdateInput, TeamCreateInput,
33};
34use tandem_memory::types::{MemorySearchResult, MemoryTier};
35use tandem_memory::MemoryManager;
36use tandem_types::{
37    SharedToolProgressSink, TenantContext, ToolProgressEvent, ToolResult, ToolSchema,
38};
39
40#[async_trait]
41pub trait Tool: Send + Sync {
42    fn schema(&self) -> ToolSchema;
43    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult>;
44    async fn execute_for_tenant(
45        &self,
46        args: Value,
47        _tenant_context: TenantContext,
48    ) -> anyhow::Result<ToolResult> {
49        self.execute(args).await
50    }
51    async fn execute_with_cancel(
52        &self,
53        args: Value,
54        _cancel: CancellationToken,
55    ) -> anyhow::Result<ToolResult> {
56        self.execute(args).await
57    }
58    async fn execute_with_progress(
59        &self,
60        args: Value,
61        cancel: CancellationToken,
62        progress: Option<SharedToolProgressSink>,
63    ) -> anyhow::Result<ToolResult> {
64        let _ = progress;
65        self.execute_with_cancel(args, cancel).await
66    }
67    async fn execute_with_progress_for_tenant(
68        &self,
69        args: Value,
70        tenant_context: TenantContext,
71        cancel: CancellationToken,
72        progress: Option<SharedToolProgressSink>,
73    ) -> anyhow::Result<ToolResult> {
74        let _ = cancel;
75        let _ = progress;
76        self.execute_for_tenant(args, tenant_context).await
77    }
78}
79
80#[derive(Clone)]
81pub struct ToolRegistry {
82    tools: Arc<RwLock<HashMap<String, Arc<dyn Tool>>>>,
83    tool_vectors: Arc<RwLock<HashMap<String, Vec<f32>>>>,
84}
85
86impl ToolRegistry {
87    pub fn new() -> Self {
88        let mut map: HashMap<String, Arc<dyn Tool>> = HashMap::new();
89        map.insert("bash".to_string(), Arc::new(BashTool));
90        map.insert("read".to_string(), Arc::new(ReadTool));
91        map.insert("write".to_string(), Arc::new(WriteTool));
92        map.insert("edit".to_string(), Arc::new(EditTool));
93        map.insert("glob".to_string(), Arc::new(GlobTool));
94        map.insert("grep".to_string(), Arc::new(GrepTool));
95        map.insert("webfetch".to_string(), Arc::new(WebFetchTool));
96        map.insert("webfetch_html".to_string(), Arc::new(WebFetchHtmlTool));
97        map.insert("mcp_debug".to_string(), Arc::new(McpDebugTool));
98        // `websearch` stays registered and resolves the live managed settings on demand so
99        // control-panel changes take effect without restarting tandem-engine.
100        map.insert("websearch".to_string(), Arc::new(WebSearchTool));
101        map.insert("codesearch".to_string(), Arc::new(CodeSearchTool));
102        let todo_tool: Arc<dyn Tool> = Arc::new(TodoWriteTool);
103        map.insert("todo_write".to_string(), todo_tool.clone());
104        map.insert("todowrite".to_string(), todo_tool.clone());
105        map.insert("update_todo_list".to_string(), todo_tool);
106        map.insert("task".to_string(), Arc::new(TaskTool));
107        map.insert("question".to_string(), Arc::new(QuestionTool));
108        map.insert("spawn_agent".to_string(), Arc::new(SpawnAgentTool));
109        map.insert("skill".to_string(), Arc::new(SkillTool));
110        map.insert("memory_store".to_string(), Arc::new(MemoryStoreTool));
111        map.insert("memory_list".to_string(), Arc::new(MemoryListTool));
112        map.insert("memory_search".to_string(), Arc::new(MemorySearchTool));
113        map.insert("memory_delete".to_string(), Arc::new(MemoryDeleteTool));
114        map.insert("apply_patch".to_string(), Arc::new(ApplyPatchTool));
115        map.insert("batch".to_string(), Arc::new(BatchTool));
116        map.insert("lsp".to_string(), Arc::new(LspTool));
117        map.insert("teamcreate".to_string(), Arc::new(TeamCreateTool));
118        map.insert("taskcreate".to_string(), Arc::new(TaskCreateCompatTool));
119        map.insert("taskupdate".to_string(), Arc::new(TaskUpdateCompatTool));
120        map.insert("tasklist".to_string(), Arc::new(TaskListCompatTool));
121        map.insert("sendmessage".to_string(), Arc::new(SendMessageCompatTool));
122        Self {
123            tools: Arc::new(RwLock::new(map)),
124            tool_vectors: Arc::new(RwLock::new(HashMap::new())),
125        }
126    }
127
128    pub async fn list(&self) -> Vec<ToolSchema> {
129        let mut dedup: HashMap<String, ToolSchema> = HashMap::new();
130        for schema in self.tools.read().await.values().map(|t| t.schema()) {
131            dedup.entry(schema.name.clone()).or_insert(schema);
132        }
133        let mut schemas = dedup.into_values().collect::<Vec<_>>();
134        schemas.sort_by(|a, b| a.name.cmp(&b.name));
135        schemas
136    }
137
138    pub async fn register_tool(&self, name: String, tool: Arc<dyn Tool>) {
139        let schema = tool.schema();
140        self.tools.write().await.insert(name.clone(), tool);
141        self.index_tool_schema(&schema).await;
142        if name != schema.name {
143            self.index_tool_name(&name, &schema).await;
144        }
145    }
146
147    pub async fn unregister_tool(&self, name: &str) -> bool {
148        let removed = self.tools.write().await.remove(name);
149        self.tool_vectors.write().await.remove(name);
150        if let Some(tool) = removed {
151            let schema_name = tool.schema().name;
152            self.tool_vectors.write().await.remove(&schema_name);
153            return true;
154        }
155        false
156    }
157
158    pub async fn unregister_by_prefix(&self, prefix: &str) -> usize {
159        let mut tools = self.tools.write().await;
160        let keys = tools
161            .keys()
162            .filter(|name| name.starts_with(prefix))
163            .cloned()
164            .collect::<Vec<_>>();
165        let removed = keys.len();
166        let mut removed_schema_names = Vec::new();
167        for key in keys {
168            if let Some(tool) = tools.remove(&key) {
169                removed_schema_names.push(tool.schema().name);
170            }
171        }
172        drop(tools);
173        let mut vectors = self.tool_vectors.write().await;
174        vectors.retain(|name, _| {
175            !name.starts_with(prefix) && !removed_schema_names.iter().any(|schema| schema == name)
176        });
177        removed
178    }
179
180    pub async fn index_all(&self) {
181        let schemas = self.list().await;
182        if schemas.is_empty() {
183            self.tool_vectors.write().await.clear();
184            return;
185        }
186        let texts = schemas
187            .iter()
188            .map(|schema| format!("{}: {}", schema.name, schema.description))
189            .collect::<Vec<_>>();
190        let service = get_embedding_service().await;
191        let service = service.lock().await;
192        if !service.is_available() {
193            return;
194        }
195        let Ok(vectors) = service.embed_batch(&texts).await else {
196            return;
197        };
198        drop(service);
199        let mut indexed = HashMap::new();
200        for (schema, vector) in schemas.into_iter().zip(vectors) {
201            indexed.insert(schema.name, vector);
202        }
203        *self.tool_vectors.write().await = indexed;
204    }
205
206    async fn index_tool_schema(&self, schema: &ToolSchema) {
207        self.index_tool_name(&schema.name, schema).await;
208    }
209
210    async fn index_tool_name(&self, name: &str, schema: &ToolSchema) {
211        let text = format!("{}: {}", schema.name, schema.description);
212        let service = get_embedding_service().await;
213        let service = service.lock().await;
214        if !service.is_available() {
215            return;
216        }
217        let Ok(vector) = service.embed(&text).await else {
218            return;
219        };
220        drop(service);
221        self.tool_vectors
222            .write()
223            .await
224            .insert(name.to_string(), vector);
225    }
226
227    pub async fn retrieve(&self, query: &str, k: usize) -> Vec<ToolSchema> {
228        if k == 0 {
229            return Vec::new();
230        }
231        let service = get_embedding_service().await;
232        let service = service.lock().await;
233        if !service.is_available() {
234            drop(service);
235            return self.list().await;
236        }
237        let Ok(query_vec) = service.embed(query).await else {
238            drop(service);
239            return self.list().await;
240        };
241        drop(service);
242
243        let vectors = self.tool_vectors.read().await;
244        if vectors.is_empty() {
245            drop(vectors);
246            return self.list().await;
247        }
248        let tools = self.tools.read().await;
249        let mut scored = vectors
250            .iter()
251            .map(|(name, vector)| {
252                (
253                    EmbeddingService::cosine_similarity(&query_vec, vector),
254                    name.clone(),
255                )
256            })
257            .collect::<Vec<_>>();
258        scored.sort_by(|a, b| {
259            b.0.partial_cmp(&a.0)
260                .unwrap_or(std::cmp::Ordering::Equal)
261                .then_with(|| a.1.cmp(&b.1))
262        });
263        let mut out = Vec::new();
264        let mut seen = HashSet::new();
265        for (_, name) in scored.into_iter().take(k) {
266            let Some(tool) = tools.get(&name) else {
267                continue;
268            };
269            let schema = tool.schema();
270            if seen.insert(schema.name.clone()) {
271                out.push(schema);
272            }
273        }
274        if out.is_empty() {
275            self.list().await
276        } else {
277            out
278        }
279    }
280
281    pub async fn mcp_server_names(&self) -> Vec<String> {
282        let mut names = HashSet::new();
283        for schema in self.list().await {
284            let mut parts = schema.name.split('.');
285            if parts.next() == Some("mcp") {
286                if let Some(server) = parts.next() {
287                    if !server.trim().is_empty() {
288                        names.insert(server.to_string());
289                    }
290                }
291            }
292        }
293        let mut sorted = names.into_iter().collect::<Vec<_>>();
294        sorted.sort();
295        sorted
296    }
297
298    pub async fn execute(&self, name: &str, args: Value) -> anyhow::Result<ToolResult> {
299        self.execute_for_tenant(name, args, TenantContext::local_implicit())
300            .await
301    }
302
303    pub async fn execute_for_tenant(
304        &self,
305        name: &str,
306        args: Value,
307        tenant_context: TenantContext,
308    ) -> anyhow::Result<ToolResult> {
309        let tool = {
310            let tools = self.tools.read().await;
311            resolve_registered_tool(&tools, name)
312        };
313        let Some(tool) = tool else {
314            return Ok(ToolResult {
315                output: format!("Unknown tool: {name}"),
316                metadata: json!({}),
317            });
318        };
319        tool.execute_for_tenant(args, tenant_context).await
320    }
321
322    pub async fn execute_with_cancel(
323        &self,
324        name: &str,
325        args: Value,
326        cancel: CancellationToken,
327    ) -> anyhow::Result<ToolResult> {
328        self.execute_with_cancel_and_progress(name, args, cancel, None)
329            .await
330    }
331
332    pub async fn execute_with_cancel_and_progress(
333        &self,
334        name: &str,
335        args: Value,
336        cancel: CancellationToken,
337        progress: Option<SharedToolProgressSink>,
338    ) -> anyhow::Result<ToolResult> {
339        self.execute_with_cancel_and_progress_for_tenant(
340            name,
341            args,
342            TenantContext::local_implicit(),
343            cancel,
344            progress,
345        )
346        .await
347    }
348
349    pub async fn execute_with_cancel_and_progress_for_tenant(
350        &self,
351        name: &str,
352        args: Value,
353        tenant_context: TenantContext,
354        cancel: CancellationToken,
355        progress: Option<SharedToolProgressSink>,
356    ) -> anyhow::Result<ToolResult> {
357        let tool = {
358            let tools = self.tools.read().await;
359            resolve_registered_tool(&tools, name)
360        };
361        let Some(tool) = tool else {
362            return Ok(ToolResult {
363                output: format!("Unknown tool: {name}"),
364                metadata: json!({}),
365            });
366        };
367        tool.execute_with_progress_for_tenant(args, tenant_context, cancel, progress)
368            .await
369    }
370}
371
372#[derive(Clone, Debug, PartialEq, Eq)]
373enum SearchBackendKind {
374    Disabled,
375    Auto,
376    Tandem,
377    Searxng,
378    Exa,
379    Brave,
380}
381
382#[derive(Clone, Debug)]
383enum SearchBackend {
384    Disabled {
385        reason: String,
386    },
387    Auto {
388        backends: Vec<SearchBackend>,
389    },
390    Tandem {
391        base_url: String,
392        timeout_ms: u64,
393    },
394    Searxng {
395        base_url: String,
396        engines: Option<String>,
397        timeout_ms: u64,
398    },
399    Exa {
400        api_key: String,
401        timeout_ms: u64,
402    },
403    Brave {
404        api_key: String,
405        timeout_ms: u64,
406    },
407}
408
409impl SearchBackend {
410    fn from_env() -> Self {
411        let managed_env = load_managed_search_env();
412        let explicit = search_setting_value(&managed_env, &["TANDEM_SEARCH_BACKEND"])
413            .map(|value| value.trim().to_ascii_lowercase())
414            .filter(|value| !value.is_empty());
415        let timeout_ms = search_backend_timeout_ms(&managed_env);
416
417        match explicit.as_deref() {
418            Some("none") | Some("disabled") => {
419                return Self::Disabled {
420                    reason: "TANDEM_SEARCH_BACKEND explicitly disabled websearch".to_string(),
421                };
422            }
423            Some("auto") => {
424                return search_backend_from_auto_env(&managed_env, timeout_ms);
425            }
426            Some("tandem") => {
427                return search_backend_from_tandem_env(&managed_env, timeout_ms, true);
428            }
429            Some("searxng") => {
430                return search_backend_from_searxng_env(&managed_env, timeout_ms).unwrap_or_else(
431                    || Self::Disabled {
432                        reason: "TANDEM_SEARCH_BACKEND=searxng but TANDEM_SEARXNG_URL is missing"
433                            .to_string(),
434                    },
435                );
436            }
437            Some("exa") => {
438                return search_backend_from_exa_env(&managed_env, timeout_ms).unwrap_or_else(|| {
439                    Self::Disabled {
440                        reason:
441                            "TANDEM_SEARCH_BACKEND=exa but EXA_API_KEY/TANDEM_EXA_API_KEY is missing"
442                                .to_string(),
443                    }
444                });
445            }
446            Some("brave") => {
447                return search_backend_from_brave_env(&managed_env, timeout_ms).unwrap_or_else(|| {
448                    Self::Disabled {
449                        reason:
450                            "TANDEM_SEARCH_BACKEND=brave but BRAVE_SEARCH_API_KEY/TANDEM_BRAVE_SEARCH_API_KEY is missing"
451                                .to_string(),
452                    }
453                });
454            }
455            Some(other) => {
456                return Self::Disabled {
457                    reason: format!(
458                        "TANDEM_SEARCH_BACKEND `{other}` is unsupported; expected auto, tandem, searxng, exa, brave, or none"
459                    ),
460                };
461            }
462            None => {}
463        }
464        search_backend_from_auto_env(&managed_env, timeout_ms)
465    }
466
467    fn kind(&self) -> SearchBackendKind {
468        match self {
469            Self::Disabled { .. } => SearchBackendKind::Disabled,
470            Self::Auto { .. } => SearchBackendKind::Auto,
471            Self::Tandem { .. } => SearchBackendKind::Tandem,
472            Self::Searxng { .. } => SearchBackendKind::Searxng,
473            Self::Exa { .. } => SearchBackendKind::Exa,
474            Self::Brave { .. } => SearchBackendKind::Brave,
475        }
476    }
477
478    fn name(&self) -> &'static str {
479        match self.kind() {
480            SearchBackendKind::Disabled => "disabled",
481            SearchBackendKind::Auto => "auto",
482            SearchBackendKind::Tandem => "tandem",
483            SearchBackendKind::Searxng => "searxng",
484            SearchBackendKind::Exa => "exa",
485            SearchBackendKind::Brave => "brave",
486        }
487    }
488
489    fn schema_description(&self) -> String {
490        match self {
491            Self::Auto { .. } => {
492                "Search web results using the configured search backends with automatic failover"
493                    .to_string()
494            }
495            Self::Tandem { .. } => {
496                "Search web results using Tandem's hosted search backend".to_string()
497            }
498            Self::Searxng { .. } => {
499                "Search web results using the configured SearxNG backend".to_string()
500            }
501            Self::Exa { .. } => "Search web results using the configured Exa backend".to_string(),
502            Self::Brave { .. } => {
503                "Search web results using the configured Brave Search backend".to_string()
504            }
505            Self::Disabled { .. } => {
506                "Search web results using the configured search backend".to_string()
507            }
508        }
509    }
510}
511
512const DEFAULT_MANAGED_SEARCH_SETTINGS_PATH: &str = "/etc/tandem/engine.env";
513
514fn managed_search_settings_path() -> PathBuf {
515    std::env::var("TANDEM_SEARCH_SETTINGS_FILE")
516        .ok()
517        .map(|value| value.trim().to_string())
518        .filter(|value| !value.is_empty())
519        .map(PathBuf::from)
520        .unwrap_or_else(|| PathBuf::from(DEFAULT_MANAGED_SEARCH_SETTINGS_PATH))
521}
522
523fn load_managed_search_env() -> HashMap<String, String> {
524    let path = managed_search_settings_path();
525    let Ok(raw) = std::fs::read_to_string(path) else {
526        return HashMap::new();
527    };
528
529    let mut env = HashMap::new();
530    for line in raw.lines() {
531        let line = line.trim();
532        if line.is_empty() || line.starts_with('#') {
533            continue;
534        }
535        let Some((key, value)) = line.split_once('=') else {
536            continue;
537        };
538        let key = key.trim();
539        if key.is_empty() {
540            continue;
541        }
542        let mut value = value.trim().to_string();
543        if ((value.starts_with('"') && value.ends_with('"'))
544            || (value.starts_with('\'') && value.ends_with('\'')))
545            && value.len() >= 2
546        {
547            value = value[1..value.len() - 1].to_string();
548        }
549        env.insert(key.to_string(), value);
550    }
551    env
552}
553
554fn search_setting_value(file_env: &HashMap<String, String>, keys: &[&str]) -> Option<String> {
555    for key in keys {
556        if let Some(value) = file_env.get(*key) {
557            let trimmed = value.trim();
558            if !trimmed.is_empty() {
559                return Some(trimmed.to_string());
560            }
561        }
562    }
563    for key in keys {
564        if let Ok(value) = std::env::var(key) {
565            let trimmed = value.trim();
566            if !trimmed.is_empty() {
567                return Some(trimmed.to_string());
568            }
569        }
570    }
571    None
572}
573
574fn has_nonempty_search_setting(file_env: &HashMap<String, String>, name: &str) -> bool {
575    search_setting_value(file_env, &[name]).is_some()
576}
577
578fn search_backend_timeout_ms(file_env: &HashMap<String, String>) -> u64 {
579    search_setting_value(file_env, &["TANDEM_SEARCH_TIMEOUT_MS"])
580        .and_then(|value| value.parse::<u64>().ok())
581        .unwrap_or(10_000)
582        .clamp(1_000, 120_000)
583}
584
585fn search_backend_from_tandem_env(
586    file_env: &HashMap<String, String>,
587    timeout_ms: u64,
588    allow_default_url: bool,
589) -> SearchBackend {
590    const DEFAULT_TANDEM_SEARCH_URL: &str = "https://search.tandem.ac";
591    let base_url = search_setting_value(file_env, &["TANDEM_SEARCH_URL"])
592        .map(|value| value.trim().trim_end_matches('/').to_string())
593        .or_else(|| allow_default_url.then(|| DEFAULT_TANDEM_SEARCH_URL.to_string()));
594    match base_url {
595        Some(base_url) => SearchBackend::Tandem {
596            base_url,
597            timeout_ms,
598        },
599        None => SearchBackend::Disabled {
600            reason: "TANDEM_SEARCH_BACKEND=tandem but TANDEM_SEARCH_URL is missing".to_string(),
601        },
602    }
603}
604
605fn search_backend_from_searxng_env(
606    file_env: &HashMap<String, String>,
607    timeout_ms: u64,
608) -> Option<SearchBackend> {
609    let base_url = search_setting_value(file_env, &["TANDEM_SEARXNG_URL"])?;
610    let base_url = base_url.trim().trim_end_matches('/').to_string();
611    if base_url.is_empty() {
612        return None;
613    }
614    let engines = search_setting_value(file_env, &["TANDEM_SEARXNG_ENGINES"])
615        .map(|value| value.trim().to_string())
616        .filter(|value| !value.is_empty());
617    Some(SearchBackend::Searxng {
618        base_url,
619        engines,
620        timeout_ms,
621    })
622}
623
624fn search_backend_from_exa_env(
625    file_env: &HashMap<String, String>,
626    timeout_ms: u64,
627) -> Option<SearchBackend> {
628    let api_key = search_setting_value(
629        file_env,
630        &[
631            "TANDEM_EXA_API_KEY",
632            "TANDEM_EXA_SEARCH_API_KEY",
633            "EXA_API_KEY",
634        ],
635    )?;
636    let api_key = api_key.trim().to_string();
637    if api_key.is_empty() {
638        return None;
639    }
640    Some(SearchBackend::Exa {
641        api_key,
642        timeout_ms,
643    })
644}
645
646fn search_backend_from_brave_env(
647    file_env: &HashMap<String, String>,
648    timeout_ms: u64,
649) -> Option<SearchBackend> {
650    let api_key = search_setting_value(
651        file_env,
652        &["TANDEM_BRAVE_SEARCH_API_KEY", "BRAVE_SEARCH_API_KEY"],
653    )?;
654    let api_key = api_key.trim().to_string();
655    if api_key.is_empty() {
656        return None;
657    }
658    Some(SearchBackend::Brave {
659        api_key,
660        timeout_ms,
661    })
662}
663
664fn search_backend_auto_candidates(
665    file_env: &HashMap<String, String>,
666    timeout_ms: u64,
667) -> Vec<SearchBackend> {
668    let mut backends = Vec::new();
669
670    if has_nonempty_search_setting(file_env, "TANDEM_SEARCH_URL") {
671        backends.push(search_backend_from_tandem_env(file_env, timeout_ms, false));
672    }
673    if let Some(config) = search_backend_from_searxng_env(file_env, timeout_ms) {
674        backends.push(config);
675    }
676    if let Some(config) = search_backend_from_brave_env(file_env, timeout_ms) {
677        backends.push(config);
678    }
679    if let Some(config) = search_backend_from_exa_env(file_env, timeout_ms) {
680        backends.push(config);
681    }
682    if backends.is_empty() {
683        backends.push(search_backend_from_tandem_env(file_env, timeout_ms, true));
684    }
685
686    backends
687        .into_iter()
688        .filter(|backend| !matches!(backend, SearchBackend::Disabled { .. }))
689        .collect()
690}
691
692fn search_backend_from_auto_env(
693    file_env: &HashMap<String, String>,
694    timeout_ms: u64,
695) -> SearchBackend {
696    let backends = search_backend_auto_candidates(file_env, timeout_ms);
697    match backends.len() {
698        0 => SearchBackend::Disabled {
699            reason:
700                "set TANDEM_SEARCH_URL or configure tandem, searxng, brave, or exa to enable websearch"
701                    .to_string(),
702        },
703        1 => backends.into_iter().next().expect("single backend"),
704        _ => SearchBackend::Auto { backends },
705    }
706}
707
708#[derive(Clone, Debug, serde::Serialize)]
709struct SearchResultEntry {
710    title: String,
711    url: String,
712    snippet: String,
713    source: String,
714}
715
716fn canonical_tool_name(name: &str) -> String {
717    match name.trim().to_ascii_lowercase().replace('-', "_").as_str() {
718        "todowrite" | "update_todo_list" | "update_todos" => "todo_write".to_string(),
719        "run_command" | "shell" | "powershell" | "cmd" => "bash".to_string(),
720        other => other.to_string(),
721    }
722}
723
724fn strip_known_tool_namespace(name: &str) -> Option<String> {
725    const PREFIXES: [&str; 8] = [
726        "default_api:",
727        "default_api.",
728        "functions.",
729        "function.",
730        "tools.",
731        "tool.",
732        "builtin:",
733        "builtin.",
734    ];
735    for prefix in PREFIXES {
736        if let Some(rest) = name.strip_prefix(prefix) {
737            let trimmed = rest.trim();
738            if !trimmed.is_empty() {
739                return Some(trimmed.to_string());
740            }
741        }
742    }
743    None
744}
745
746fn resolve_registered_tool(
747    tools: &HashMap<String, Arc<dyn Tool>>,
748    requested_name: &str,
749) -> Option<Arc<dyn Tool>> {
750    let canonical = canonical_tool_name(requested_name);
751    if let Some(tool) = tools.get(&canonical) {
752        return Some(tool.clone());
753    }
754    if let Some(stripped) = strip_known_tool_namespace(&canonical) {
755        let stripped = canonical_tool_name(&stripped);
756        if let Some(tool) = tools.get(&stripped) {
757            return Some(tool.clone());
758        }
759    }
760    None
761}
762
763fn is_batch_wrapper_tool_name(name: &str) -> bool {
764    matches!(
765        canonical_tool_name(name).as_str(),
766        "default_api" | "default" | "api" | "function" | "functions" | "tool" | "tools"
767    )
768}
769
770fn non_empty_batch_str(value: Option<&Value>) -> Option<&str> {
771    trimmed_non_empty_str(value)
772}
773
774fn resolve_batch_call_tool_name(call: &Value) -> Option<String> {
775    let tool = non_empty_batch_str(call.get("tool"))
776        .or_else(|| {
777            call.get("tool")
778                .and_then(|v| v.as_object())
779                .and_then(|obj| non_empty_batch_str(obj.get("name")))
780        })
781        .or_else(|| {
782            call.get("function")
783                .and_then(|v| v.as_object())
784                .and_then(|obj| non_empty_batch_str(obj.get("tool")))
785        })
786        .or_else(|| {
787            call.get("function_call")
788                .and_then(|v| v.as_object())
789                .and_then(|obj| non_empty_batch_str(obj.get("tool")))
790        })
791        .or_else(|| {
792            call.get("call")
793                .and_then(|v| v.as_object())
794                .and_then(|obj| non_empty_batch_str(obj.get("tool")))
795        });
796    let name = non_empty_batch_str(call.get("name"))
797        .or_else(|| {
798            call.get("function")
799                .and_then(|v| v.as_object())
800                .and_then(|obj| non_empty_batch_str(obj.get("name")))
801        })
802        .or_else(|| {
803            call.get("function_call")
804                .and_then(|v| v.as_object())
805                .and_then(|obj| non_empty_batch_str(obj.get("name")))
806        })
807        .or_else(|| {
808            call.get("call")
809                .and_then(|v| v.as_object())
810                .and_then(|obj| non_empty_batch_str(obj.get("name")))
811        })
812        .or_else(|| {
813            call.get("tool")
814                .and_then(|v| v.as_object())
815                .and_then(|obj| non_empty_batch_str(obj.get("name")))
816        });
817
818    match (tool, name) {
819        (Some(t), Some(n)) => {
820            if is_batch_wrapper_tool_name(t) {
821                Some(n.to_string())
822            } else if let Some(stripped) = strip_known_tool_namespace(t) {
823                Some(stripped)
824            } else {
825                Some(t.to_string())
826            }
827        }
828        (Some(t), None) => {
829            if is_batch_wrapper_tool_name(t) {
830                None
831            } else if let Some(stripped) = strip_known_tool_namespace(t) {
832                Some(stripped)
833            } else {
834                Some(t.to_string())
835            }
836        }
837        (None, Some(n)) => Some(n.to_string()),
838        (None, None) => None,
839    }
840}
841
842impl Default for ToolRegistry {
843    fn default() -> Self {
844        Self::new()
845    }
846}
847
848#[derive(Debug, Clone, PartialEq, Eq)]
849pub struct ToolSchemaValidationError {
850    pub tool_name: String,
851    pub path: String,
852    pub reason: String,
853}
854
855impl std::fmt::Display for ToolSchemaValidationError {
856    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
857        write!(
858            f,
859            "invalid tool schema `{}` at `{}`: {}",
860            self.tool_name, self.path, self.reason
861        )
862    }
863}
864
865impl std::error::Error for ToolSchemaValidationError {}
866
867pub fn validate_tool_schemas(schemas: &[ToolSchema]) -> Result<(), ToolSchemaValidationError> {
868    for schema in schemas {
869        validate_schema_node(&schema.name, "$", &schema.input_schema)?;
870    }
871    Ok(())
872}
873
874fn validate_schema_node(
875    tool_name: &str,
876    path: &str,
877    value: &Value,
878) -> Result<(), ToolSchemaValidationError> {
879    let Some(obj) = value.as_object() else {
880        if let Some(arr) = value.as_array() {
881            for (idx, item) in arr.iter().enumerate() {
882                validate_schema_node(tool_name, &format!("{path}[{idx}]"), item)?;
883            }
884        }
885        return Ok(());
886    };
887
888    if obj.get("type").and_then(|t| t.as_str()) == Some("array") && !obj.contains_key("items") {
889        return Err(ToolSchemaValidationError {
890            tool_name: tool_name.to_string(),
891            path: path.to_string(),
892            reason: "array schema missing items".to_string(),
893        });
894    }
895
896    if let Some(items) = obj.get("items") {
897        validate_schema_node(tool_name, &format!("{path}.items"), items)?;
898    }
899    if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
900        for (key, child) in props {
901            validate_schema_node(tool_name, &format!("{path}.properties.{key}"), child)?;
902        }
903    }
904    if let Some(additional_props) = obj.get("additionalProperties") {
905        validate_schema_node(
906            tool_name,
907            &format!("{path}.additionalProperties"),
908            additional_props,
909        )?;
910    }
911    if let Some(one_of) = obj.get("oneOf").and_then(|v| v.as_array()) {
912        for (idx, child) in one_of.iter().enumerate() {
913            validate_schema_node(tool_name, &format!("{path}.oneOf[{idx}]"), child)?;
914        }
915    }
916    if let Some(any_of) = obj.get("anyOf").and_then(|v| v.as_array()) {
917        for (idx, child) in any_of.iter().enumerate() {
918            validate_schema_node(tool_name, &format!("{path}.anyOf[{idx}]"), child)?;
919        }
920    }
921    if let Some(all_of) = obj.get("allOf").and_then(|v| v.as_array()) {
922        for (idx, child) in all_of.iter().enumerate() {
923            validate_schema_node(tool_name, &format!("{path}.allOf[{idx}]"), child)?;
924        }
925    }
926
927    Ok(())
928}
929
930fn workspace_root_from_args(args: &Value) -> Option<PathBuf> {
931    args.get("__workspace_root")
932        .and_then(|v| v.as_str())
933        .map(str::trim)
934        .filter(|s| !s.is_empty())
935        .map(PathBuf::from)
936}
937
938fn effective_cwd_from_args(args: &Value) -> PathBuf {
939    args.get("__effective_cwd")
940        .and_then(|v| v.as_str())
941        .map(str::trim)
942        .filter(|s| !s.is_empty())
943        .map(PathBuf::from)
944        .or_else(|| workspace_root_from_args(args))
945        .or_else(|| std::env::current_dir().ok())
946        .unwrap_or_else(|| PathBuf::from("."))
947}
948
949fn normalize_path_for_compare(path: &Path) -> PathBuf {
950    let mut normalized = PathBuf::new();
951    for component in path.components() {
952        match component {
953            std::path::Component::CurDir => {}
954            std::path::Component::ParentDir => {
955                let _ = normalized.pop();
956            }
957            other => normalized.push(other.as_os_str()),
958        }
959    }
960    normalized
961}
962
963fn normalize_existing_or_lexical(path: &Path) -> PathBuf {
964    path.canonicalize()
965        .unwrap_or_else(|_| normalize_path_for_compare(path))
966}
967
968fn is_within_workspace_root(path: &Path, workspace_root: &Path) -> bool {
969    let root = normalize_existing_or_lexical(workspace_root);
970    let candidate = if path.exists() {
971        normalize_existing_or_lexical(path)
972    } else if let (Some(parent), Some(name)) = (path.parent(), path.file_name()) {
973        normalize_existing_or_lexical(parent).join(name)
974    } else {
975        normalize_path_for_compare(path)
976    };
977    candidate == root || candidate.starts_with(&root)
978}
979
980fn resolve_tool_path(path: &str, args: &Value) -> Option<PathBuf> {
981    let trimmed = path.trim();
982    if trimmed.is_empty() {
983        return None;
984    }
985    if trimmed == "." || trimmed == "./" || trimmed == ".\\" {
986        let cwd = effective_cwd_from_args(args);
987        if let Some(workspace_root) = workspace_root_from_args(args) {
988            if !is_within_workspace_root(&cwd, &workspace_root) {
989                return None;
990            }
991        }
992        return Some(cwd);
993    }
994    if is_root_only_path_token(trimmed) || is_malformed_tool_path_token(trimmed) {
995        return None;
996    }
997    let raw = Path::new(trimmed);
998    if !raw.is_absolute()
999        && raw
1000            .components()
1001            .any(|c| matches!(c, std::path::Component::ParentDir))
1002    {
1003        return None;
1004    }
1005
1006    let resolved = if raw.is_absolute() {
1007        raw.to_path_buf()
1008    } else {
1009        effective_cwd_from_args(args).join(raw)
1010    };
1011
1012    if let Some(workspace_root) = workspace_root_from_args(args) {
1013        if !is_within_workspace_root(&resolved, &workspace_root) {
1014            return None;
1015        }
1016    } else if raw.is_absolute() {
1017        return None;
1018    }
1019
1020    Some(resolved)
1021}
1022
1023fn resolve_walk_root(path: &str, args: &Value) -> Option<PathBuf> {
1024    let trimmed = path.trim();
1025    if trimmed.is_empty() {
1026        return None;
1027    }
1028    if is_malformed_tool_path_token(trimmed) {
1029        return None;
1030    }
1031    resolve_tool_path(path, args)
1032}
1033
1034fn resolve_read_path_fallback(path: &str, args: &Value) -> Option<PathBuf> {
1035    let token = path.trim();
1036    if token.is_empty() {
1037        return None;
1038    }
1039    let raw = Path::new(token);
1040    if raw.is_absolute() || token.contains('\\') || token.contains('/') || raw.extension().is_none()
1041    {
1042        return None;
1043    }
1044
1045    let workspace_root = workspace_root_from_args(args);
1046    let effective_cwd = effective_cwd_from_args(args);
1047    let mut search_roots = vec![effective_cwd.clone()];
1048    if let Some(root) = workspace_root.as_ref() {
1049        if *root != effective_cwd {
1050            search_roots.push(root.clone());
1051        }
1052    }
1053
1054    let token_lower = token.to_lowercase();
1055    for root in search_roots {
1056        if let Some(workspace_root) = workspace_root.as_ref() {
1057            if !is_within_workspace_root(&root, workspace_root) {
1058                continue;
1059            }
1060        }
1061
1062        let mut matches = Vec::new();
1063        for entry in WalkBuilder::new(&root).build().flatten() {
1064            if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
1065                continue;
1066            }
1067            let candidate = entry.path();
1068            if let Some(workspace_root) = workspace_root.as_ref() {
1069                if !is_within_workspace_root(candidate, workspace_root) {
1070                    continue;
1071                }
1072            }
1073            let file_name = candidate
1074                .file_name()
1075                .and_then(|name| name.to_str())
1076                .unwrap_or_default()
1077                .to_lowercase();
1078            if file_name == token_lower || file_name.ends_with(&token_lower) {
1079                matches.push(candidate.to_path_buf());
1080                if matches.len() > 8 {
1081                    break;
1082                }
1083            }
1084        }
1085
1086        if matches.len() == 1 {
1087            return matches.into_iter().next();
1088        }
1089    }
1090
1091    None
1092}
1093
1094fn sandbox_path_denied_result(path: &str, args: &Value) -> ToolResult {
1095    let requested = path.trim();
1096    let workspace_root = workspace_root_from_args(args);
1097    let effective_cwd = effective_cwd_from_args(args);
1098    let suggested_path = Path::new(requested)
1099        .file_name()
1100        .filter(|name| !name.is_empty())
1101        .map(PathBuf::from)
1102        .map(|name| {
1103            if let Some(root) = workspace_root.as_ref() {
1104                if is_within_workspace_root(&effective_cwd, root) {
1105                    effective_cwd.join(name)
1106                } else {
1107                    root.join(name)
1108                }
1109            } else {
1110                effective_cwd.join(name)
1111            }
1112        });
1113
1114    let mut output =
1115        "path denied by sandbox policy (outside workspace root, malformed path, or missing workspace context)"
1116            .to_string();
1117    if let Some(suggested) = suggested_path.as_ref() {
1118        output.push_str(&format!(
1119            "\nrequested: {}\ntry: {}",
1120            requested,
1121            suggested.to_string_lossy()
1122        ));
1123    }
1124    if let Some(root) = workspace_root.as_ref() {
1125        output.push_str(&format!("\nworkspace_root: {}", root.to_string_lossy()));
1126    }
1127
1128    ToolResult {
1129        output,
1130        metadata: json!({
1131            "path": path,
1132            "workspace_root": workspace_root.map(|p| p.to_string_lossy().to_string()),
1133            "effective_cwd": effective_cwd.to_string_lossy().to_string(),
1134            "suggested_path": suggested_path.map(|p| p.to_string_lossy().to_string())
1135        }),
1136    }
1137}
1138
1139fn is_root_only_path_token(path: &str) -> bool {
1140    if matches!(path, "/" | "\\" | "." | ".." | "~") {
1141        return true;
1142    }
1143    let bytes = path.as_bytes();
1144    if bytes.len() == 2 && bytes[1] == b':' && (bytes[0] as char).is_ascii_alphabetic() {
1145        return true;
1146    }
1147    if bytes.len() == 3
1148        && bytes[1] == b':'
1149        && (bytes[0] as char).is_ascii_alphabetic()
1150        && (bytes[2] == b'\\' || bytes[2] == b'/')
1151    {
1152        return true;
1153    }
1154    false
1155}
1156
1157fn is_malformed_tool_path_token(path: &str) -> bool {
1158    let lower = path.to_ascii_lowercase();
1159    if lower.contains("<tool_call")
1160        || lower.contains("</tool_call")
1161        || lower.contains("<function=")
1162        || lower.contains("<parameter=")
1163        || lower.contains("</function>")
1164        || lower.contains("</parameter>")
1165    {
1166        return true;
1167    }
1168    if path.contains('\n') || path.contains('\r') {
1169        return true;
1170    }
1171    if path.contains('*') {
1172        return true;
1173    }
1174    // Allow Windows verbatim prefixes (\\?\C:\... / //?/C:/... / \\?\UNC\...).
1175    // These can appear in tool outputs and should not be treated as malformed.
1176    if path.contains('?') {
1177        let trimmed = path.trim();
1178        let is_windows_verbatim = trimmed.starts_with("\\\\?\\") || trimmed.starts_with("//?/");
1179        if !is_windows_verbatim {
1180            return true;
1181        }
1182    }
1183    false
1184}
1185
1186fn is_malformed_tool_pattern_token(pattern: &str) -> bool {
1187    let lower = pattern.to_ascii_lowercase();
1188    if lower.contains("<tool_call")
1189        || lower.contains("</tool_call")
1190        || lower.contains("<function=")
1191        || lower.contains("<parameter=")
1192        || lower.contains("</function>")
1193        || lower.contains("</parameter>")
1194    {
1195        return true;
1196    }
1197    if pattern.contains('\n') || pattern.contains('\r') {
1198        return true;
1199    }
1200    false
1201}
1202
1203// Builtin shell/read tool implementations live in `builtin_tools`.
1204
1205struct WriteTool;
1206#[async_trait]
1207impl Tool for WriteTool {
1208    fn schema(&self) -> ToolSchema {
1209        tool_schema_with_capabilities(
1210            "write",
1211            "Write file contents",
1212            json!({
1213                "type":"object",
1214                "properties":{
1215                    "path":{"type":"string"},
1216                    "content":{"type":"string"},
1217                    "allow_empty":{"type":"boolean"}
1218                },
1219                "required":["path", "content"]
1220            }),
1221            workspace_write_capabilities(),
1222        )
1223    }
1224    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1225        let path = args["path"].as_str().unwrap_or("").trim();
1226        let content = args["content"].as_str();
1227        let allow_empty = args
1228            .get("allow_empty")
1229            .and_then(|v| v.as_bool())
1230            .unwrap_or(false);
1231        let Some(path_buf) = resolve_tool_path(path, &args) else {
1232            return Ok(sandbox_path_denied_result(path, &args));
1233        };
1234        let Some(content) = content else {
1235            return Ok(ToolResult {
1236                output: "write requires `content`".to_string(),
1237                metadata: json!({"ok": false, "reason": "missing_content", "path": path}),
1238            });
1239        };
1240        if content.is_empty() && !allow_empty {
1241            return Ok(ToolResult {
1242                output: "write requires non-empty `content` (or set allow_empty=true)".to_string(),
1243                metadata: json!({"ok": false, "reason": "empty_content", "path": path}),
1244            });
1245        }
1246        if let Some(parent) = path_buf.parent() {
1247            if !parent.as_os_str().is_empty() {
1248                fs::create_dir_all(parent).await?;
1249            }
1250        }
1251        fs::write(&path_buf, content).await?;
1252        Ok(ToolResult {
1253            output: "ok".to_string(),
1254            metadata: json!({"path": path_buf.to_string_lossy()}),
1255        })
1256    }
1257}
1258
1259struct EditTool;
1260#[async_trait]
1261impl Tool for EditTool {
1262    fn schema(&self) -> ToolSchema {
1263        tool_schema_with_capabilities(
1264            "edit",
1265            "String replacement edit",
1266            json!({
1267                "type":"object",
1268                "properties":{
1269                    "path":{"type":"string"},
1270                    "old":{"type":"string"},
1271                    "new":{"type":"string"}
1272                },
1273                "required":["path", "old", "new"]
1274            }),
1275            workspace_write_capabilities(),
1276        )
1277    }
1278    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1279        let path = args["path"].as_str().unwrap_or("");
1280        let old = args["old"].as_str().unwrap_or("");
1281        let new = args["new"].as_str().unwrap_or("");
1282        let Some(path_buf) = resolve_tool_path(path, &args) else {
1283            return Ok(sandbox_path_denied_result(path, &args));
1284        };
1285        let content = fs::read_to_string(&path_buf).await.unwrap_or_default();
1286        let updated = content.replace(old, new);
1287        fs::write(&path_buf, updated).await?;
1288        Ok(ToolResult {
1289            output: "ok".to_string(),
1290            metadata: json!({"path": path_buf.to_string_lossy()}),
1291        })
1292    }
1293}
1294
1295struct GlobTool;
1296
1297fn normalize_recursive_wildcard_pattern(pattern: &str) -> Option<String> {
1298    let mut changed = false;
1299    let normalized = pattern
1300        .split('/')
1301        .flat_map(|component| {
1302            if let Some(tail) = component.strip_prefix("**") {
1303                if !tail.is_empty() {
1304                    changed = true;
1305                    let normalized_tail = if tail.starts_with('.') || tail.starts_with('{') {
1306                        format!("*{tail}")
1307                    } else {
1308                        tail.to_string()
1309                    };
1310                    return vec!["**".to_string(), normalized_tail];
1311                }
1312            }
1313            vec![component.to_string()]
1314        })
1315        .collect::<Vec<_>>()
1316        .join("/");
1317    changed.then_some(normalized)
1318}
1319
1320#[async_trait]
1321impl Tool for GlobTool {
1322    fn schema(&self) -> ToolSchema {
1323        tool_schema_with_capabilities(
1324            "glob",
1325            "Find files by glob",
1326            json!({"type":"object","properties":{"pattern":{"type":"string"}}}),
1327            workspace_search_capabilities(),
1328        )
1329    }
1330    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1331        let pattern = args["pattern"].as_str().unwrap_or("*");
1332        if pattern.contains("..") {
1333            return Ok(ToolResult {
1334                output: "pattern denied by sandbox policy".to_string(),
1335                metadata: json!({"pattern": pattern}),
1336            });
1337        }
1338        if is_malformed_tool_pattern_token(pattern) {
1339            return Ok(ToolResult {
1340                output: "pattern denied by sandbox policy".to_string(),
1341                metadata: json!({"pattern": pattern}),
1342            });
1343        }
1344        let workspace_root = workspace_root_from_args(&args);
1345        let effective_cwd = effective_cwd_from_args(&args);
1346        let scoped_pattern = if Path::new(pattern).is_absolute() {
1347            pattern.to_string()
1348        } else {
1349            effective_cwd.join(pattern).to_string_lossy().to_string()
1350        };
1351        let mut files = Vec::new();
1352        let mut effective_pattern = scoped_pattern.clone();
1353        let paths = match glob::glob(&scoped_pattern) {
1354            Ok(paths) => paths,
1355            Err(err) => {
1356                if let Some(normalized) = normalize_recursive_wildcard_pattern(&scoped_pattern) {
1357                    if let Ok(paths) = glob::glob(&normalized) {
1358                        effective_pattern = normalized;
1359                        paths
1360                    } else {
1361                        return Err(err.into());
1362                    }
1363                } else {
1364                    return Err(err.into());
1365                }
1366            }
1367        };
1368        for path in paths.flatten() {
1369            if is_discovery_ignored_path(&path) {
1370                continue;
1371            }
1372            if let Some(root) = workspace_root.as_ref() {
1373                if !is_within_workspace_root(&path, root) {
1374                    continue;
1375                }
1376            }
1377            files.push(path.display().to_string());
1378            if files.len() >= 100 {
1379                break;
1380            }
1381        }
1382        Ok(ToolResult {
1383            output: files.join("\n"),
1384            metadata: json!({
1385                "count": files.len(),
1386                "effective_cwd": effective_cwd,
1387                "workspace_root": workspace_root,
1388                "pattern": pattern,
1389                "effective_pattern": effective_pattern
1390            }),
1391        })
1392    }
1393}
1394
1395fn is_discovery_ignored_path(path: &Path) -> bool {
1396    let components: Vec<_> = path.components().collect();
1397    for (idx, component) in components.iter().enumerate() {
1398        if component.as_os_str() == ".tandem" {
1399            let next = components
1400                .get(idx + 1)
1401                .map(|component| component.as_os_str());
1402            return next != Some(std::ffi::OsStr::new("artifacts"));
1403        }
1404    }
1405    false
1406}
1407
1408struct GrepTool;
1409
1410#[derive(Debug, Clone)]
1411struct GrepHit {
1412    path: String,
1413    line: usize,
1414    text: String,
1415    ordinal: usize,
1416}
1417
1418fn grep_hit_to_value(hit: &GrepHit) -> Value {
1419    json!({
1420        "path": hit.path,
1421        "line": hit.line,
1422        "text": hit.text,
1423        "ordinal": hit.ordinal,
1424    })
1425}
1426
1427fn emit_grep_progress_chunk(
1428    progress: Option<&SharedToolProgressSink>,
1429    tool: &str,
1430    hits: &[GrepHit],
1431) {
1432    let Some(progress) = progress else {
1433        return;
1434    };
1435    if hits.is_empty() {
1436        return;
1437    }
1438    progress.publish(ToolProgressEvent::new(
1439        "tool.search.chunk",
1440        json!({
1441            "tool": tool,
1442            "hits": hits.iter().map(grep_hit_to_value).collect::<Vec<_>>(),
1443        }),
1444    ));
1445}
1446
1447fn emit_grep_progress_done(
1448    progress: Option<&SharedToolProgressSink>,
1449    tool: &str,
1450    path: &Path,
1451    total_hits: usize,
1452    truncated: bool,
1453    cancelled: bool,
1454) {
1455    let Some(progress) = progress else {
1456        return;
1457    };
1458    progress.publish(ToolProgressEvent::new(
1459        "tool.search.done",
1460        json!({
1461            "tool": tool,
1462            "path": path.to_string_lossy(),
1463            "count": total_hits,
1464            "truncated": truncated,
1465            "cancelled": cancelled,
1466        }),
1467    ));
1468}
1469
1470struct GrepSearchState {
1471    hits: Mutex<Vec<GrepHit>>,
1472    hit_count: AtomicUsize,
1473    stop: AtomicBool,
1474    cancel: CancellationToken,
1475    limit: usize,
1476    chunk_size: usize,
1477    progress: Option<SharedToolProgressSink>,
1478}