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