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