Skip to main content

tandem_tools/
lib.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
38mod builtin_tools;
39mod tool_metadata;
40use builtin_tools::*;
41use tool_metadata::*;
42
43#[async_trait]
44pub trait Tool: Send + Sync {
45    fn schema(&self) -> ToolSchema;
46    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult>;
47    async fn execute_with_cancel(
48        &self,
49        args: Value,
50        _cancel: CancellationToken,
51    ) -> anyhow::Result<ToolResult> {
52        self.execute(args).await
53    }
54    async fn execute_with_progress(
55        &self,
56        args: Value,
57        cancel: CancellationToken,
58        progress: Option<SharedToolProgressSink>,
59    ) -> anyhow::Result<ToolResult> {
60        let _ = progress;
61        self.execute_with_cancel(args, cancel).await
62    }
63}
64
65#[derive(Clone)]
66pub struct ToolRegistry {
67    tools: Arc<RwLock<HashMap<String, Arc<dyn Tool>>>>,
68    tool_vectors: Arc<RwLock<HashMap<String, Vec<f32>>>>,
69}
70
71impl ToolRegistry {
72    pub fn new() -> Self {
73        let mut map: HashMap<String, Arc<dyn Tool>> = HashMap::new();
74        map.insert("bash".to_string(), Arc::new(BashTool));
75        map.insert("read".to_string(), Arc::new(ReadTool));
76        map.insert("write".to_string(), Arc::new(WriteTool));
77        map.insert("edit".to_string(), Arc::new(EditTool));
78        map.insert("glob".to_string(), Arc::new(GlobTool));
79        map.insert("grep".to_string(), Arc::new(GrepTool));
80        map.insert("webfetch".to_string(), Arc::new(WebFetchTool));
81        map.insert("webfetch_html".to_string(), Arc::new(WebFetchHtmlTool));
82        map.insert("mcp_debug".to_string(), Arc::new(McpDebugTool));
83        let search_backend = SearchBackend::from_env();
84        if search_backend.is_enabled() {
85            map.insert(
86                "websearch".to_string(),
87                Arc::new(WebSearchTool {
88                    backend: search_backend,
89                }),
90            );
91        } else {
92            tracing::info!(
93                reason = search_backend.disabled_reason().unwrap_or("unknown"),
94                "builtin websearch disabled because no search backend is configured"
95            );
96        }
97        map.insert("codesearch".to_string(), Arc::new(CodeSearchTool));
98        let todo_tool: Arc<dyn Tool> = Arc::new(TodoWriteTool);
99        map.insert("todo_write".to_string(), todo_tool.clone());
100        map.insert("todowrite".to_string(), todo_tool.clone());
101        map.insert("update_todo_list".to_string(), todo_tool);
102        map.insert("task".to_string(), Arc::new(TaskTool));
103        map.insert("question".to_string(), Arc::new(QuestionTool));
104        map.insert("spawn_agent".to_string(), Arc::new(SpawnAgentTool));
105        map.insert("skill".to_string(), Arc::new(SkillTool));
106        map.insert("memory_store".to_string(), Arc::new(MemoryStoreTool));
107        map.insert("memory_list".to_string(), Arc::new(MemoryListTool));
108        map.insert("memory_search".to_string(), Arc::new(MemorySearchTool));
109        map.insert("memory_delete".to_string(), Arc::new(MemoryDeleteTool));
110        map.insert("apply_patch".to_string(), Arc::new(ApplyPatchTool));
111        map.insert("batch".to_string(), Arc::new(BatchTool));
112        map.insert("lsp".to_string(), Arc::new(LspTool));
113        map.insert("teamcreate".to_string(), Arc::new(TeamCreateTool));
114        map.insert("taskcreate".to_string(), Arc::new(TaskCreateCompatTool));
115        map.insert("taskupdate".to_string(), Arc::new(TaskUpdateCompatTool));
116        map.insert("tasklist".to_string(), Arc::new(TaskListCompatTool));
117        map.insert("sendmessage".to_string(), Arc::new(SendMessageCompatTool));
118        Self {
119            tools: Arc::new(RwLock::new(map)),
120            tool_vectors: Arc::new(RwLock::new(HashMap::new())),
121        }
122    }
123
124    pub async fn list(&self) -> Vec<ToolSchema> {
125        let mut dedup: HashMap<String, ToolSchema> = HashMap::new();
126        for schema in self.tools.read().await.values().map(|t| t.schema()) {
127            dedup.entry(schema.name.clone()).or_insert(schema);
128        }
129        let mut schemas = dedup.into_values().collect::<Vec<_>>();
130        schemas.sort_by(|a, b| a.name.cmp(&b.name));
131        schemas
132    }
133
134    pub async fn register_tool(&self, name: String, tool: Arc<dyn Tool>) {
135        let schema = tool.schema();
136        self.tools.write().await.insert(name.clone(), tool);
137        self.index_tool_schema(&schema).await;
138        if name != schema.name {
139            self.index_tool_name(&name, &schema).await;
140        }
141    }
142
143    pub async fn unregister_tool(&self, name: &str) -> bool {
144        let removed = self.tools.write().await.remove(name);
145        self.tool_vectors.write().await.remove(name);
146        if let Some(tool) = removed {
147            let schema_name = tool.schema().name;
148            self.tool_vectors.write().await.remove(&schema_name);
149            return true;
150        }
151        false
152    }
153
154    pub async fn unregister_by_prefix(&self, prefix: &str) -> usize {
155        let mut tools = self.tools.write().await;
156        let keys = tools
157            .keys()
158            .filter(|name| name.starts_with(prefix))
159            .cloned()
160            .collect::<Vec<_>>();
161        let removed = keys.len();
162        let mut removed_schema_names = Vec::new();
163        for key in keys {
164            if let Some(tool) = tools.remove(&key) {
165                removed_schema_names.push(tool.schema().name);
166            }
167        }
168        drop(tools);
169        let mut vectors = self.tool_vectors.write().await;
170        vectors.retain(|name, _| {
171            !name.starts_with(prefix) && !removed_schema_names.iter().any(|schema| schema == name)
172        });
173        removed
174    }
175
176    pub async fn index_all(&self) {
177        let schemas = self.list().await;
178        if schemas.is_empty() {
179            self.tool_vectors.write().await.clear();
180            return;
181        }
182        let texts = schemas
183            .iter()
184            .map(|schema| format!("{}: {}", schema.name, schema.description))
185            .collect::<Vec<_>>();
186        let service = get_embedding_service().await;
187        let service = service.lock().await;
188        if !service.is_available() {
189            return;
190        }
191        let Ok(vectors) = service.embed_batch(&texts).await else {
192            return;
193        };
194        drop(service);
195        let mut indexed = HashMap::new();
196        for (schema, vector) in schemas.into_iter().zip(vectors) {
197            indexed.insert(schema.name, vector);
198        }
199        *self.tool_vectors.write().await = indexed;
200    }
201
202    async fn index_tool_schema(&self, schema: &ToolSchema) {
203        self.index_tool_name(&schema.name, schema).await;
204    }
205
206    async fn index_tool_name(&self, name: &str, schema: &ToolSchema) {
207        let text = format!("{}: {}", schema.name, schema.description);
208        let service = get_embedding_service().await;
209        let service = service.lock().await;
210        if !service.is_available() {
211            return;
212        }
213        let Ok(vector) = service.embed(&text).await else {
214            return;
215        };
216        drop(service);
217        self.tool_vectors
218            .write()
219            .await
220            .insert(name.to_string(), vector);
221    }
222
223    pub async fn retrieve(&self, query: &str, k: usize) -> Vec<ToolSchema> {
224        if k == 0 {
225            return Vec::new();
226        }
227        let service = get_embedding_service().await;
228        let service = service.lock().await;
229        if !service.is_available() {
230            drop(service);
231            return self.list().await;
232        }
233        let Ok(query_vec) = service.embed(query).await else {
234            drop(service);
235            return self.list().await;
236        };
237        drop(service);
238
239        let vectors = self.tool_vectors.read().await;
240        if vectors.is_empty() {
241            drop(vectors);
242            return self.list().await;
243        }
244        let tools = self.tools.read().await;
245        let mut scored = vectors
246            .iter()
247            .map(|(name, vector)| {
248                (
249                    EmbeddingService::cosine_similarity(&query_vec, vector),
250                    name.clone(),
251                )
252            })
253            .collect::<Vec<_>>();
254        scored.sort_by(|a, b| {
255            b.0.partial_cmp(&a.0)
256                .unwrap_or(std::cmp::Ordering::Equal)
257                .then_with(|| a.1.cmp(&b.1))
258        });
259        let mut out = Vec::new();
260        let mut seen = HashSet::new();
261        for (_, name) in scored.into_iter().take(k) {
262            let Some(tool) = tools.get(&name) else {
263                continue;
264            };
265            let schema = tool.schema();
266            if seen.insert(schema.name.clone()) {
267                out.push(schema);
268            }
269        }
270        if out.is_empty() {
271            self.list().await
272        } else {
273            out
274        }
275    }
276
277    pub async fn mcp_server_names(&self) -> Vec<String> {
278        let mut names = HashSet::new();
279        for schema in self.list().await {
280            let mut parts = schema.name.split('.');
281            if parts.next() == Some("mcp") {
282                if let Some(server) = parts.next() {
283                    if !server.trim().is_empty() {
284                        names.insert(server.to_string());
285                    }
286                }
287            }
288        }
289        let mut sorted = names.into_iter().collect::<Vec<_>>();
290        sorted.sort();
291        sorted
292    }
293
294    pub async fn execute(&self, name: &str, args: Value) -> anyhow::Result<ToolResult> {
295        let tool = {
296            let tools = self.tools.read().await;
297            resolve_registered_tool(&tools, name)
298        };
299        let Some(tool) = tool else {
300            return Ok(ToolResult {
301                output: format!("Unknown tool: {name}"),
302                metadata: json!({}),
303            });
304        };
305        tool.execute(args).await
306    }
307
308    pub async fn execute_with_cancel(
309        &self,
310        name: &str,
311        args: Value,
312        cancel: CancellationToken,
313    ) -> anyhow::Result<ToolResult> {
314        self.execute_with_cancel_and_progress(name, args, cancel, None)
315            .await
316    }
317
318    pub async fn execute_with_cancel_and_progress(
319        &self,
320        name: &str,
321        args: Value,
322        cancel: CancellationToken,
323        progress: Option<SharedToolProgressSink>,
324    ) -> anyhow::Result<ToolResult> {
325        let tool = {
326            let tools = self.tools.read().await;
327            resolve_registered_tool(&tools, name)
328        };
329        let Some(tool) = tool else {
330            return Ok(ToolResult {
331                output: format!("Unknown tool: {name}"),
332                metadata: json!({}),
333            });
334        };
335        tool.execute_with_progress(args, cancel, progress).await
336    }
337}
338
339#[derive(Clone, Debug, PartialEq, Eq)]
340enum SearchBackendKind {
341    Disabled,
342    Auto,
343    Tandem,
344    Searxng,
345    Exa,
346    Brave,
347}
348
349#[derive(Clone, Debug)]
350enum SearchBackend {
351    Disabled {
352        reason: String,
353    },
354    Auto {
355        backends: Vec<SearchBackend>,
356    },
357    Tandem {
358        base_url: String,
359        timeout_ms: u64,
360    },
361    Searxng {
362        base_url: String,
363        engines: Option<String>,
364        timeout_ms: u64,
365    },
366    Exa {
367        api_key: String,
368        timeout_ms: u64,
369    },
370    Brave {
371        api_key: String,
372        timeout_ms: u64,
373    },
374}
375
376impl SearchBackend {
377    fn from_env() -> Self {
378        let explicit = std::env::var("TANDEM_SEARCH_BACKEND")
379            .ok()
380            .map(|value| value.trim().to_ascii_lowercase())
381            .filter(|value| !value.is_empty());
382        let timeout_ms = search_backend_timeout_ms();
383
384        match explicit.as_deref() {
385            Some("none") | Some("disabled") => {
386                return Self::Disabled {
387                    reason: "TANDEM_SEARCH_BACKEND explicitly disabled websearch".to_string(),
388                };
389            }
390            Some("auto") => {
391                return search_backend_from_auto_env(timeout_ms);
392            }
393            Some("tandem") => {
394                return search_backend_from_tandem_env(timeout_ms, true);
395            }
396            Some("searxng") => {
397                return search_backend_from_searxng_env(timeout_ms).unwrap_or_else(|| {
398                    Self::Disabled {
399                        reason: "TANDEM_SEARCH_BACKEND=searxng but TANDEM_SEARXNG_URL is missing"
400                            .to_string(),
401                    }
402                });
403            }
404            Some("exa") => {
405                return search_backend_from_exa_env(timeout_ms).unwrap_or_else(|| Self::Disabled {
406                    reason:
407                        "TANDEM_SEARCH_BACKEND=exa but EXA_API_KEY/TANDEM_EXA_API_KEY is missing"
408                            .to_string(),
409                });
410            }
411            Some("brave") => {
412                return search_backend_from_brave_env(timeout_ms).unwrap_or_else(|| {
413                    Self::Disabled {
414                        reason:
415                            "TANDEM_SEARCH_BACKEND=brave but BRAVE_SEARCH_API_KEY/TANDEM_BRAVE_SEARCH_API_KEY is missing"
416                                .to_string(),
417                    }
418                });
419            }
420            Some(other) => {
421                return Self::Disabled {
422                    reason: format!(
423                        "TANDEM_SEARCH_BACKEND `{other}` is unsupported; expected auto, tandem, searxng, exa, brave, or none"
424                    ),
425                };
426            }
427            None => {}
428        }
429        search_backend_from_auto_env(timeout_ms)
430    }
431
432    fn is_enabled(&self) -> bool {
433        !matches!(self, Self::Disabled { .. })
434    }
435
436    fn kind(&self) -> SearchBackendKind {
437        match self {
438            Self::Disabled { .. } => SearchBackendKind::Disabled,
439            Self::Auto { .. } => SearchBackendKind::Auto,
440            Self::Tandem { .. } => SearchBackendKind::Tandem,
441            Self::Searxng { .. } => SearchBackendKind::Searxng,
442            Self::Exa { .. } => SearchBackendKind::Exa,
443            Self::Brave { .. } => SearchBackendKind::Brave,
444        }
445    }
446
447    fn name(&self) -> &'static str {
448        match self.kind() {
449            SearchBackendKind::Disabled => "disabled",
450            SearchBackendKind::Auto => "auto",
451            SearchBackendKind::Tandem => "tandem",
452            SearchBackendKind::Searxng => "searxng",
453            SearchBackendKind::Exa => "exa",
454            SearchBackendKind::Brave => "brave",
455        }
456    }
457
458    fn disabled_reason(&self) -> Option<&str> {
459        match self {
460            Self::Disabled { reason } => Some(reason.as_str()),
461            _ => None,
462        }
463    }
464
465    fn schema_description(&self) -> String {
466        match self {
467            Self::Auto { .. } => {
468                "Search web results using the configured search backends with automatic failover"
469                    .to_string()
470            }
471            Self::Tandem { .. } => {
472                "Search web results using Tandem's hosted search backend".to_string()
473            }
474            Self::Searxng { .. } => {
475                "Search web results using the configured SearxNG backend".to_string()
476            }
477            Self::Exa { .. } => "Search web results using the configured Exa backend".to_string(),
478            Self::Brave { .. } => {
479                "Search web results using the configured Brave Search backend".to_string()
480            }
481            Self::Disabled { .. } => {
482                "Search web results using the configured search backend".to_string()
483            }
484        }
485    }
486}
487
488fn has_nonempty_env_var(name: &str) -> bool {
489    std::env::var(name)
490        .ok()
491        .map(|value| !value.trim().is_empty())
492        .unwrap_or(false)
493}
494
495fn search_backend_timeout_ms() -> u64 {
496    std::env::var("TANDEM_SEARCH_TIMEOUT_MS")
497        .ok()
498        .and_then(|value| value.trim().parse::<u64>().ok())
499        .unwrap_or(10_000)
500        .clamp(1_000, 120_000)
501}
502
503fn search_backend_from_tandem_env(timeout_ms: u64, allow_default_url: bool) -> SearchBackend {
504    const DEFAULT_TANDEM_SEARCH_URL: &str = "https://search.tandem.ac";
505    let base_url = std::env::var("TANDEM_SEARCH_URL")
506        .ok()
507        .map(|value| value.trim().trim_end_matches('/').to_string())
508        .filter(|value| !value.is_empty())
509        .or_else(|| allow_default_url.then(|| DEFAULT_TANDEM_SEARCH_URL.to_string()));
510    match base_url {
511        Some(base_url) => SearchBackend::Tandem {
512            base_url,
513            timeout_ms,
514        },
515        None => SearchBackend::Disabled {
516            reason: "TANDEM_SEARCH_BACKEND=tandem but TANDEM_SEARCH_URL is missing".to_string(),
517        },
518    }
519}
520
521fn search_backend_from_searxng_env(timeout_ms: u64) -> Option<SearchBackend> {
522    let base_url = std::env::var("TANDEM_SEARXNG_URL").ok()?;
523    let base_url = base_url.trim().trim_end_matches('/').to_string();
524    if base_url.is_empty() {
525        return None;
526    }
527    let engines = std::env::var("TANDEM_SEARXNG_ENGINES")
528        .ok()
529        .map(|value| value.trim().to_string())
530        .filter(|value| !value.is_empty());
531    Some(SearchBackend::Searxng {
532        base_url,
533        engines,
534        timeout_ms,
535    })
536}
537
538fn search_backend_from_exa_env(timeout_ms: u64) -> Option<SearchBackend> {
539    let api_key = std::env::var("TANDEM_EXA_API_KEY")
540        .ok()
541        .or_else(|| std::env::var("TANDEM_EXA_SEARCH_API_KEY").ok())
542        .or_else(|| std::env::var("EXA_API_KEY").ok())?;
543    let api_key = api_key.trim().to_string();
544    if api_key.is_empty() {
545        return None;
546    }
547    Some(SearchBackend::Exa {
548        api_key,
549        timeout_ms,
550    })
551}
552
553fn search_backend_from_brave_env(timeout_ms: u64) -> Option<SearchBackend> {
554    let api_key = std::env::var("TANDEM_BRAVE_SEARCH_API_KEY")
555        .ok()
556        .or_else(|| std::env::var("BRAVE_SEARCH_API_KEY").ok())?;
557    let api_key = api_key.trim().to_string();
558    if api_key.is_empty() {
559        return None;
560    }
561    Some(SearchBackend::Brave {
562        api_key,
563        timeout_ms,
564    })
565}
566
567fn search_backend_auto_candidates(timeout_ms: u64) -> Vec<SearchBackend> {
568    let mut backends = Vec::new();
569
570    if has_nonempty_env_var("TANDEM_SEARCH_URL") {
571        backends.push(search_backend_from_tandem_env(timeout_ms, false));
572    }
573    if let Some(config) = search_backend_from_searxng_env(timeout_ms) {
574        backends.push(config);
575    }
576    if let Some(config) = search_backend_from_brave_env(timeout_ms) {
577        backends.push(config);
578    }
579    if let Some(config) = search_backend_from_exa_env(timeout_ms) {
580        backends.push(config);
581    }
582    if backends.is_empty() {
583        backends.push(search_backend_from_tandem_env(timeout_ms, true));
584    }
585
586    backends
587        .into_iter()
588        .filter(|backend| !matches!(backend, SearchBackend::Disabled { .. }))
589        .collect()
590}
591
592fn search_backend_from_auto_env(timeout_ms: u64) -> SearchBackend {
593    let backends = search_backend_auto_candidates(timeout_ms);
594    match backends.len() {
595        0 => SearchBackend::Disabled {
596            reason:
597                "set TANDEM_SEARCH_URL or configure tandem, searxng, brave, or exa to enable websearch"
598                    .to_string(),
599        },
600        1 => backends.into_iter().next().expect("single backend"),
601        _ => SearchBackend::Auto { backends },
602    }
603}
604
605#[derive(Clone, Debug, serde::Serialize)]
606struct SearchResultEntry {
607    title: String,
608    url: String,
609    snippet: String,
610    source: String,
611}
612
613fn canonical_tool_name(name: &str) -> String {
614    match name.trim().to_ascii_lowercase().replace('-', "_").as_str() {
615        "todowrite" | "update_todo_list" | "update_todos" => "todo_write".to_string(),
616        "run_command" | "shell" | "powershell" | "cmd" => "bash".to_string(),
617        other => other.to_string(),
618    }
619}
620
621fn strip_known_tool_namespace(name: &str) -> Option<String> {
622    const PREFIXES: [&str; 8] = [
623        "default_api:",
624        "default_api.",
625        "functions.",
626        "function.",
627        "tools.",
628        "tool.",
629        "builtin:",
630        "builtin.",
631    ];
632    for prefix in PREFIXES {
633        if let Some(rest) = name.strip_prefix(prefix) {
634            let trimmed = rest.trim();
635            if !trimmed.is_empty() {
636                return Some(trimmed.to_string());
637            }
638        }
639    }
640    None
641}
642
643fn resolve_registered_tool(
644    tools: &HashMap<String, Arc<dyn Tool>>,
645    requested_name: &str,
646) -> Option<Arc<dyn Tool>> {
647    let canonical = canonical_tool_name(requested_name);
648    if let Some(tool) = tools.get(&canonical) {
649        return Some(tool.clone());
650    }
651    if let Some(stripped) = strip_known_tool_namespace(&canonical) {
652        let stripped = canonical_tool_name(&stripped);
653        if let Some(tool) = tools.get(&stripped) {
654            return Some(tool.clone());
655        }
656    }
657    None
658}
659
660fn is_batch_wrapper_tool_name(name: &str) -> bool {
661    matches!(
662        canonical_tool_name(name).as_str(),
663        "default_api" | "default" | "api" | "function" | "functions" | "tool" | "tools"
664    )
665}
666
667fn non_empty_batch_str(value: Option<&Value>) -> Option<&str> {
668    trimmed_non_empty_str(value)
669}
670
671fn resolve_batch_call_tool_name(call: &Value) -> Option<String> {
672    let tool = non_empty_batch_str(call.get("tool"))
673        .or_else(|| {
674            call.get("tool")
675                .and_then(|v| v.as_object())
676                .and_then(|obj| non_empty_batch_str(obj.get("name")))
677        })
678        .or_else(|| {
679            call.get("function")
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("function_call")
685                .and_then(|v| v.as_object())
686                .and_then(|obj| non_empty_batch_str(obj.get("tool")))
687        })
688        .or_else(|| {
689            call.get("call")
690                .and_then(|v| v.as_object())
691                .and_then(|obj| non_empty_batch_str(obj.get("tool")))
692        });
693    let name = non_empty_batch_str(call.get("name"))
694        .or_else(|| {
695            call.get("function")
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("function_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("call")
706                .and_then(|v| v.as_object())
707                .and_then(|obj| non_empty_batch_str(obj.get("name")))
708        })
709        .or_else(|| {
710            call.get("tool")
711                .and_then(|v| v.as_object())
712                .and_then(|obj| non_empty_batch_str(obj.get("name")))
713        });
714
715    match (tool, name) {
716        (Some(t), Some(n)) => {
717            if is_batch_wrapper_tool_name(t) {
718                Some(n.to_string())
719            } else if let Some(stripped) = strip_known_tool_namespace(t) {
720                Some(stripped)
721            } else {
722                Some(t.to_string())
723            }
724        }
725        (Some(t), None) => {
726            if is_batch_wrapper_tool_name(t) {
727                None
728            } else if let Some(stripped) = strip_known_tool_namespace(t) {
729                Some(stripped)
730            } else {
731                Some(t.to_string())
732            }
733        }
734        (None, Some(n)) => Some(n.to_string()),
735        (None, None) => None,
736    }
737}
738
739impl Default for ToolRegistry {
740    fn default() -> Self {
741        Self::new()
742    }
743}
744
745#[derive(Debug, Clone, PartialEq, Eq)]
746pub struct ToolSchemaValidationError {
747    pub tool_name: String,
748    pub path: String,
749    pub reason: String,
750}
751
752impl std::fmt::Display for ToolSchemaValidationError {
753    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
754        write!(
755            f,
756            "invalid tool schema `{}` at `{}`: {}",
757            self.tool_name, self.path, self.reason
758        )
759    }
760}
761
762impl std::error::Error for ToolSchemaValidationError {}
763
764pub fn validate_tool_schemas(schemas: &[ToolSchema]) -> Result<(), ToolSchemaValidationError> {
765    for schema in schemas {
766        validate_schema_node(&schema.name, "$", &schema.input_schema)?;
767    }
768    Ok(())
769}
770
771fn validate_schema_node(
772    tool_name: &str,
773    path: &str,
774    value: &Value,
775) -> Result<(), ToolSchemaValidationError> {
776    let Some(obj) = value.as_object() else {
777        if let Some(arr) = value.as_array() {
778            for (idx, item) in arr.iter().enumerate() {
779                validate_schema_node(tool_name, &format!("{path}[{idx}]"), item)?;
780            }
781        }
782        return Ok(());
783    };
784
785    if obj.get("type").and_then(|t| t.as_str()) == Some("array") && !obj.contains_key("items") {
786        return Err(ToolSchemaValidationError {
787            tool_name: tool_name.to_string(),
788            path: path.to_string(),
789            reason: "array schema missing items".to_string(),
790        });
791    }
792
793    if let Some(items) = obj.get("items") {
794        validate_schema_node(tool_name, &format!("{path}.items"), items)?;
795    }
796    if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
797        for (key, child) in props {
798            validate_schema_node(tool_name, &format!("{path}.properties.{key}"), child)?;
799        }
800    }
801    if let Some(additional_props) = obj.get("additionalProperties") {
802        validate_schema_node(
803            tool_name,
804            &format!("{path}.additionalProperties"),
805            additional_props,
806        )?;
807    }
808    if let Some(one_of) = obj.get("oneOf").and_then(|v| v.as_array()) {
809        for (idx, child) in one_of.iter().enumerate() {
810            validate_schema_node(tool_name, &format!("{path}.oneOf[{idx}]"), child)?;
811        }
812    }
813    if let Some(any_of) = obj.get("anyOf").and_then(|v| v.as_array()) {
814        for (idx, child) in any_of.iter().enumerate() {
815            validate_schema_node(tool_name, &format!("{path}.anyOf[{idx}]"), child)?;
816        }
817    }
818    if let Some(all_of) = obj.get("allOf").and_then(|v| v.as_array()) {
819        for (idx, child) in all_of.iter().enumerate() {
820            validate_schema_node(tool_name, &format!("{path}.allOf[{idx}]"), child)?;
821        }
822    }
823
824    Ok(())
825}
826
827fn workspace_root_from_args(args: &Value) -> Option<PathBuf> {
828    args.get("__workspace_root")
829        .and_then(|v| v.as_str())
830        .map(str::trim)
831        .filter(|s| !s.is_empty())
832        .map(PathBuf::from)
833}
834
835fn effective_cwd_from_args(args: &Value) -> PathBuf {
836    args.get("__effective_cwd")
837        .and_then(|v| v.as_str())
838        .map(str::trim)
839        .filter(|s| !s.is_empty())
840        .map(PathBuf::from)
841        .or_else(|| workspace_root_from_args(args))
842        .or_else(|| std::env::current_dir().ok())
843        .unwrap_or_else(|| PathBuf::from("."))
844}
845
846fn normalize_path_for_compare(path: &Path) -> PathBuf {
847    let mut normalized = PathBuf::new();
848    for component in path.components() {
849        match component {
850            std::path::Component::CurDir => {}
851            std::path::Component::ParentDir => {
852                let _ = normalized.pop();
853            }
854            other => normalized.push(other.as_os_str()),
855        }
856    }
857    normalized
858}
859
860fn normalize_existing_or_lexical(path: &Path) -> PathBuf {
861    path.canonicalize()
862        .unwrap_or_else(|_| normalize_path_for_compare(path))
863}
864
865fn is_within_workspace_root(path: &Path, workspace_root: &Path) -> bool {
866    // First compare lexical-normalized paths so non-existent target files under symlinked
867    // workspace roots still pass containment checks.
868    let candidate_lexical = normalize_path_for_compare(path);
869    let root_lexical = normalize_path_for_compare(workspace_root);
870    if candidate_lexical.starts_with(&root_lexical) {
871        return true;
872    }
873
874    // Fallback to canonical comparison when available (best for existing paths and symlink
875    // resolution consistency).
876    let candidate = normalize_existing_or_lexical(path);
877    let root = normalize_existing_or_lexical(workspace_root);
878    candidate.starts_with(root)
879}
880
881fn resolve_tool_path(path: &str, args: &Value) -> Option<PathBuf> {
882    let trimmed = path.trim();
883    if trimmed.is_empty() {
884        return None;
885    }
886    if trimmed == "." || trimmed == "./" || trimmed == ".\\" {
887        let cwd = effective_cwd_from_args(args);
888        if let Some(workspace_root) = workspace_root_from_args(args) {
889            if !is_within_workspace_root(&cwd, &workspace_root) {
890                return None;
891            }
892        }
893        return Some(cwd);
894    }
895    if is_root_only_path_token(trimmed) || is_malformed_tool_path_token(trimmed) {
896        return None;
897    }
898    let raw = Path::new(trimmed);
899    if !raw.is_absolute()
900        && raw
901            .components()
902            .any(|c| matches!(c, std::path::Component::ParentDir))
903    {
904        return None;
905    }
906
907    let resolved = if raw.is_absolute() {
908        raw.to_path_buf()
909    } else {
910        effective_cwd_from_args(args).join(raw)
911    };
912
913    if let Some(workspace_root) = workspace_root_from_args(args) {
914        if !is_within_workspace_root(&resolved, &workspace_root) {
915            return None;
916        }
917    } else if raw.is_absolute() {
918        return None;
919    }
920
921    Some(resolved)
922}
923
924fn resolve_walk_root(path: &str, args: &Value) -> Option<PathBuf> {
925    let trimmed = path.trim();
926    if trimmed.is_empty() {
927        return None;
928    }
929    if is_malformed_tool_path_token(trimmed) {
930        return None;
931    }
932    resolve_tool_path(path, args)
933}
934
935fn resolve_read_path_fallback(path: &str, args: &Value) -> Option<PathBuf> {
936    let token = path.trim();
937    if token.is_empty() {
938        return None;
939    }
940    let raw = Path::new(token);
941    if raw.is_absolute() || token.contains('\\') || token.contains('/') || raw.extension().is_none()
942    {
943        return None;
944    }
945
946    let workspace_root = workspace_root_from_args(args);
947    let effective_cwd = effective_cwd_from_args(args);
948    let mut search_roots = vec![effective_cwd.clone()];
949    if let Some(root) = workspace_root.as_ref() {
950        if *root != effective_cwd {
951            search_roots.push(root.clone());
952        }
953    }
954
955    let token_lower = token.to_lowercase();
956    for root in search_roots {
957        if let Some(workspace_root) = workspace_root.as_ref() {
958            if !is_within_workspace_root(&root, workspace_root) {
959                continue;
960            }
961        }
962
963        let mut matches = Vec::new();
964        for entry in WalkBuilder::new(&root).build().flatten() {
965            if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
966                continue;
967            }
968            let candidate = entry.path();
969            if let Some(workspace_root) = workspace_root.as_ref() {
970                if !is_within_workspace_root(candidate, workspace_root) {
971                    continue;
972                }
973            }
974            let file_name = candidate
975                .file_name()
976                .and_then(|name| name.to_str())
977                .unwrap_or_default()
978                .to_lowercase();
979            if file_name == token_lower || file_name.ends_with(&token_lower) {
980                matches.push(candidate.to_path_buf());
981                if matches.len() > 8 {
982                    break;
983                }
984            }
985        }
986
987        if matches.len() == 1 {
988            return matches.into_iter().next();
989        }
990    }
991
992    None
993}
994
995fn sandbox_path_denied_result(path: &str, args: &Value) -> ToolResult {
996    let requested = path.trim();
997    let workspace_root = workspace_root_from_args(args);
998    let effective_cwd = effective_cwd_from_args(args);
999    let suggested_path = Path::new(requested)
1000        .file_name()
1001        .filter(|name| !name.is_empty())
1002        .map(PathBuf::from)
1003        .map(|name| {
1004            if let Some(root) = workspace_root.as_ref() {
1005                if is_within_workspace_root(&effective_cwd, root) {
1006                    effective_cwd.join(name)
1007                } else {
1008                    root.join(name)
1009                }
1010            } else {
1011                effective_cwd.join(name)
1012            }
1013        });
1014
1015    let mut output =
1016        "path denied by sandbox policy (outside workspace root, malformed path, or missing workspace context)"
1017            .to_string();
1018    if let Some(suggested) = suggested_path.as_ref() {
1019        output.push_str(&format!(
1020            "\nrequested: {}\ntry: {}",
1021            requested,
1022            suggested.to_string_lossy()
1023        ));
1024    }
1025    if let Some(root) = workspace_root.as_ref() {
1026        output.push_str(&format!("\nworkspace_root: {}", root.to_string_lossy()));
1027    }
1028
1029    ToolResult {
1030        output,
1031        metadata: json!({
1032            "path": path,
1033            "workspace_root": workspace_root.map(|p| p.to_string_lossy().to_string()),
1034            "effective_cwd": effective_cwd.to_string_lossy().to_string(),
1035            "suggested_path": suggested_path.map(|p| p.to_string_lossy().to_string())
1036        }),
1037    }
1038}
1039
1040fn is_root_only_path_token(path: &str) -> bool {
1041    if matches!(path, "/" | "\\" | "." | ".." | "~") {
1042        return true;
1043    }
1044    let bytes = path.as_bytes();
1045    if bytes.len() == 2 && bytes[1] == b':' && (bytes[0] as char).is_ascii_alphabetic() {
1046        return true;
1047    }
1048    if bytes.len() == 3
1049        && bytes[1] == b':'
1050        && (bytes[0] as char).is_ascii_alphabetic()
1051        && (bytes[2] == b'\\' || bytes[2] == b'/')
1052    {
1053        return true;
1054    }
1055    false
1056}
1057
1058fn is_malformed_tool_path_token(path: &str) -> bool {
1059    let lower = path.to_ascii_lowercase();
1060    if lower.contains("<tool_call")
1061        || lower.contains("</tool_call")
1062        || lower.contains("<function=")
1063        || lower.contains("<parameter=")
1064        || lower.contains("</function>")
1065        || lower.contains("</parameter>")
1066    {
1067        return true;
1068    }
1069    if path.contains('\n') || path.contains('\r') {
1070        return true;
1071    }
1072    if path.contains('*') {
1073        return true;
1074    }
1075    // Allow Windows verbatim prefixes (\\?\C:\... / //?/C:/... / \\?\UNC\...).
1076    // These can appear in tool outputs and should not be treated as malformed.
1077    if path.contains('?') {
1078        let trimmed = path.trim();
1079        let is_windows_verbatim = trimmed.starts_with("\\\\?\\") || trimmed.starts_with("//?/");
1080        if !is_windows_verbatim {
1081            return true;
1082        }
1083    }
1084    false
1085}
1086
1087fn is_malformed_tool_pattern_token(pattern: &str) -> bool {
1088    let lower = pattern.to_ascii_lowercase();
1089    if lower.contains("<tool_call")
1090        || lower.contains("</tool_call")
1091        || lower.contains("<function=")
1092        || lower.contains("<parameter=")
1093        || lower.contains("</function>")
1094        || lower.contains("</parameter>")
1095    {
1096        return true;
1097    }
1098    if pattern.contains('\n') || pattern.contains('\r') {
1099        return true;
1100    }
1101    false
1102}
1103
1104// Builtin shell/read tool implementations live in `builtin_tools`.
1105
1106struct WriteTool;
1107#[async_trait]
1108impl Tool for WriteTool {
1109    fn schema(&self) -> ToolSchema {
1110        tool_schema_with_capabilities(
1111            "write",
1112            "Write file contents",
1113            json!({
1114                "type":"object",
1115                "properties":{
1116                    "path":{"type":"string"},
1117                    "content":{"type":"string"},
1118                    "allow_empty":{"type":"boolean"}
1119                },
1120                "required":["path", "content"]
1121            }),
1122            workspace_write_capabilities(),
1123        )
1124    }
1125    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1126        let path = args["path"].as_str().unwrap_or("").trim();
1127        let content = args["content"].as_str();
1128        let allow_empty = args
1129            .get("allow_empty")
1130            .and_then(|v| v.as_bool())
1131            .unwrap_or(false);
1132        let Some(path_buf) = resolve_tool_path(path, &args) else {
1133            return Ok(sandbox_path_denied_result(path, &args));
1134        };
1135        let Some(content) = content else {
1136            return Ok(ToolResult {
1137                output: "write requires `content`".to_string(),
1138                metadata: json!({"ok": false, "reason": "missing_content", "path": path}),
1139            });
1140        };
1141        if content.is_empty() && !allow_empty {
1142            return Ok(ToolResult {
1143                output: "write requires non-empty `content` (or set allow_empty=true)".to_string(),
1144                metadata: json!({"ok": false, "reason": "empty_content", "path": path}),
1145            });
1146        }
1147        if let Some(parent) = path_buf.parent() {
1148            if !parent.as_os_str().is_empty() {
1149                fs::create_dir_all(parent).await?;
1150            }
1151        }
1152        fs::write(&path_buf, content).await?;
1153        Ok(ToolResult {
1154            output: "ok".to_string(),
1155            metadata: json!({"path": path_buf.to_string_lossy()}),
1156        })
1157    }
1158}
1159
1160struct EditTool;
1161#[async_trait]
1162impl Tool for EditTool {
1163    fn schema(&self) -> ToolSchema {
1164        tool_schema_with_capabilities(
1165            "edit",
1166            "String replacement edit",
1167            json!({
1168                "type":"object",
1169                "properties":{
1170                    "path":{"type":"string"},
1171                    "old":{"type":"string"},
1172                    "new":{"type":"string"}
1173                },
1174                "required":["path", "old", "new"]
1175            }),
1176            workspace_write_capabilities(),
1177        )
1178    }
1179    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1180        let path = args["path"].as_str().unwrap_or("");
1181        let old = args["old"].as_str().unwrap_or("");
1182        let new = args["new"].as_str().unwrap_or("");
1183        let Some(path_buf) = resolve_tool_path(path, &args) else {
1184            return Ok(sandbox_path_denied_result(path, &args));
1185        };
1186        let content = fs::read_to_string(&path_buf).await.unwrap_or_default();
1187        let updated = content.replace(old, new);
1188        fs::write(&path_buf, updated).await?;
1189        Ok(ToolResult {
1190            output: "ok".to_string(),
1191            metadata: json!({"path": path_buf.to_string_lossy()}),
1192        })
1193    }
1194}
1195
1196struct GlobTool;
1197
1198fn normalize_recursive_wildcard_pattern(pattern: &str) -> Option<String> {
1199    let mut changed = false;
1200    let normalized = pattern
1201        .split('/')
1202        .flat_map(|component| {
1203            if let Some(tail) = component.strip_prefix("**") {
1204                if !tail.is_empty() {
1205                    changed = true;
1206                    let normalized_tail = if tail.starts_with('.') || tail.starts_with('{') {
1207                        format!("*{tail}")
1208                    } else {
1209                        tail.to_string()
1210                    };
1211                    return vec!["**".to_string(), normalized_tail];
1212                }
1213            }
1214            vec![component.to_string()]
1215        })
1216        .collect::<Vec<_>>()
1217        .join("/");
1218    changed.then_some(normalized)
1219}
1220
1221#[async_trait]
1222impl Tool for GlobTool {
1223    fn schema(&self) -> ToolSchema {
1224        tool_schema_with_capabilities(
1225            "glob",
1226            "Find files by glob",
1227            json!({"type":"object","properties":{"pattern":{"type":"string"}}}),
1228            workspace_search_capabilities(),
1229        )
1230    }
1231    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1232        let pattern = args["pattern"].as_str().unwrap_or("*");
1233        if pattern.contains("..") {
1234            return Ok(ToolResult {
1235                output: "pattern denied by sandbox policy".to_string(),
1236                metadata: json!({"pattern": pattern}),
1237            });
1238        }
1239        if is_malformed_tool_pattern_token(pattern) {
1240            return Ok(ToolResult {
1241                output: "pattern denied by sandbox policy".to_string(),
1242                metadata: json!({"pattern": pattern}),
1243            });
1244        }
1245        let workspace_root = workspace_root_from_args(&args);
1246        let effective_cwd = effective_cwd_from_args(&args);
1247        let scoped_pattern = if Path::new(pattern).is_absolute() {
1248            pattern.to_string()
1249        } else {
1250            effective_cwd.join(pattern).to_string_lossy().to_string()
1251        };
1252        let mut files = Vec::new();
1253        let mut effective_pattern = scoped_pattern.clone();
1254        let paths = match glob::glob(&scoped_pattern) {
1255            Ok(paths) => paths,
1256            Err(err) => {
1257                if let Some(normalized) = normalize_recursive_wildcard_pattern(&scoped_pattern) {
1258                    if let Ok(paths) = glob::glob(&normalized) {
1259                        effective_pattern = normalized;
1260                        paths
1261                    } else {
1262                        return Err(err.into());
1263                    }
1264                } else {
1265                    return Err(err.into());
1266                }
1267            }
1268        };
1269        for path in paths.flatten() {
1270            if is_discovery_ignored_path(&path) {
1271                continue;
1272            }
1273            if let Some(root) = workspace_root.as_ref() {
1274                if !is_within_workspace_root(&path, root) {
1275                    continue;
1276                }
1277            }
1278            files.push(path.display().to_string());
1279            if files.len() >= 100 {
1280                break;
1281            }
1282        }
1283        Ok(ToolResult {
1284            output: files.join("\n"),
1285            metadata: json!({
1286                "count": files.len(),
1287                "effective_cwd": effective_cwd,
1288                "workspace_root": workspace_root,
1289                "pattern": pattern,
1290                "effective_pattern": effective_pattern
1291            }),
1292        })
1293    }
1294}
1295
1296fn is_discovery_ignored_path(path: &Path) -> bool {
1297    let components: Vec<_> = path.components().collect();
1298    for (idx, component) in components.iter().enumerate() {
1299        if component.as_os_str() == ".tandem" {
1300            let next = components
1301                .get(idx + 1)
1302                .map(|component| component.as_os_str());
1303            return next != Some(std::ffi::OsStr::new("artifacts"));
1304        }
1305    }
1306    false
1307}
1308
1309struct GrepTool;
1310
1311#[derive(Debug, Clone)]
1312struct GrepHit {
1313    path: String,
1314    line: usize,
1315    text: String,
1316    ordinal: usize,
1317}
1318
1319fn grep_hit_to_value(hit: &GrepHit) -> Value {
1320    json!({
1321        "path": hit.path,
1322        "line": hit.line,
1323        "text": hit.text,
1324        "ordinal": hit.ordinal,
1325    })
1326}
1327
1328fn emit_grep_progress_chunk(
1329    progress: Option<&SharedToolProgressSink>,
1330    tool: &str,
1331    hits: &[GrepHit],
1332) {
1333    let Some(progress) = progress else {
1334        return;
1335    };
1336    if hits.is_empty() {
1337        return;
1338    }
1339    progress.publish(ToolProgressEvent::new(
1340        "tool.search.chunk",
1341        json!({
1342            "tool": tool,
1343            "hits": hits.iter().map(grep_hit_to_value).collect::<Vec<_>>(),
1344        }),
1345    ));
1346}
1347
1348fn emit_grep_progress_done(
1349    progress: Option<&SharedToolProgressSink>,
1350    tool: &str,
1351    path: &Path,
1352    total_hits: usize,
1353    truncated: bool,
1354    cancelled: bool,
1355) {
1356    let Some(progress) = progress else {
1357        return;
1358    };
1359    progress.publish(ToolProgressEvent::new(
1360        "tool.search.done",
1361        json!({
1362            "tool": tool,
1363            "path": path.to_string_lossy(),
1364            "count": total_hits,
1365            "truncated": truncated,
1366            "cancelled": cancelled,
1367        }),
1368    ));
1369}
1370
1371struct GrepSearchState {
1372    hits: Mutex<Vec<GrepHit>>,
1373    hit_count: AtomicUsize,
1374    stop: AtomicBool,
1375    cancel: CancellationToken,
1376    limit: usize,
1377    chunk_size: usize,
1378    progress: Option<SharedToolProgressSink>,
1379}
1380
1381impl GrepSearchState {
1382    fn new(
1383        cancel: CancellationToken,
1384        limit: usize,
1385        chunk_size: usize,
1386        progress: Option<SharedToolProgressSink>,
1387    ) -> Self {
1388        Self {
1389            hits: Mutex::new(Vec::new()),
1390            hit_count: AtomicUsize::new(0),
1391            stop: AtomicBool::new(false),
1392            cancel,
1393            limit,
1394            chunk_size,
1395            progress,
1396        }
1397    }
1398
1399    fn should_stop(&self) -> bool {
1400        self.stop.load(AtomicOrdering::Acquire) || self.cancel.is_cancelled()
1401    }
1402
1403    fn reserve_hit(&self) -> Option<usize> {
1404        if self.should_stop() {
1405            return None;
1406        }
1407        match self.hit_count.fetch_update(
1408            AtomicOrdering::AcqRel,
1409            AtomicOrdering::Acquire,
1410            |current| (current < self.limit).then_some(current + 1),
1411        ) {
1412            Ok(previous) => {
1413                let ordinal = previous + 1;
1414                if ordinal >= self.limit {
1415                    self.stop.store(true, AtomicOrdering::Release);
1416                }
1417                Some(ordinal)
1418            }
1419            Err(_) => {
1420                self.stop.store(true, AtomicOrdering::Release);
1421                None
1422            }
1423        }
1424    }
1425
1426    fn push_hit(&self, hit: GrepHit) {
1427        if let Ok(mut hits) = self.hits.lock() {
1428            hits.push(hit);
1429        }
1430    }
1431
1432    fn sorted_hits(&self) -> Vec<GrepHit> {
1433        let mut hits = self
1434            .hits
1435            .lock()
1436            .map(|hits| hits.clone())
1437            .unwrap_or_default();
1438        hits.sort_by(|a, b| {
1439            a.path
1440                .cmp(&b.path)
1441                .then_with(|| a.line.cmp(&b.line))
1442                .then_with(|| a.text.cmp(&b.text))
1443                .then_with(|| a.ordinal.cmp(&b.ordinal))
1444        });
1445        hits
1446    }
1447}
1448
1449struct GrepParallelVisitorBuilder {
1450    matcher: Arc<RegexMatcher>,
1451    state: Arc<GrepSearchState>,
1452    tool: String,
1453}
1454
1455struct GrepParallelVisitor {
1456    matcher: Arc<RegexMatcher>,
1457    state: Arc<GrepSearchState>,
1458    searcher: grep_searcher::Searcher,
1459    tool: String,
1460}
1461
1462impl<'s> ParallelVisitorBuilder<'s> for GrepParallelVisitorBuilder {
1463    fn build(&mut self) -> Box<dyn ParallelVisitor + 's> {
1464        Box::new(GrepParallelVisitor {
1465            matcher: Arc::clone(&self.matcher),
1466            state: Arc::clone(&self.state),
1467            searcher: build_grep_searcher(),
1468            tool: self.tool.clone(),
1469        })
1470    }
1471}
1472
1473impl ParallelVisitor for GrepParallelVisitor {
1474    fn visit(&mut self, entry: Result<ignore::DirEntry, ignore::Error>) -> WalkState {
1475        if self.state.should_stop() {
1476            return WalkState::Quit;
1477        }
1478        let Ok(entry) = entry else {
1479            return WalkState::Continue;
1480        };
1481        if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
1482            return WalkState::Continue;
1483        }
1484        let path = entry.path();
1485        if is_discovery_ignored_path(path) {
1486            return WalkState::Continue;
1487        }
1488        let Ok(file) = std::fs::File::open(path) else {
1489            return WalkState::Continue;
1490        };
1491        let path_display = path.display().to_string();
1492        let state = Arc::clone(&self.state);
1493        let progress = state.progress.clone();
1494        let tool = self.tool.clone();
1495        let mut pending_chunk = Vec::with_capacity(state.chunk_size);
1496        let _ = self.searcher.search_file(
1497            &*self.matcher,
1498            &file,
1499            Lossy(|line_number, line| {
1500                if state.should_stop() {
1501                    return Ok(false);
1502                }
1503                let Some(ordinal) = state.reserve_hit() else {
1504                    return Ok(false);
1505                };
1506                let line = line.trim_end_matches(['\r', '\n']);
1507                let hit = GrepHit {
1508                    path: path_display.clone(),
1509                    line: line_number as usize,
1510                    text: line.to_string(),
1511                    ordinal,
1512                };
1513                state.push_hit(hit.clone());
1514                pending_chunk.push(hit);
1515                if pending_chunk.len() >= state.chunk_size {
1516                    emit_grep_progress_chunk(progress.as_ref(), &tool, &pending_chunk);
1517                    pending_chunk.clear();
1518                }
1519                if state.should_stop() {
1520                    return Ok(false);
1521                }
1522                Ok(true)
1523            }),
1524        );
1525        emit_grep_progress_chunk(progress.as_ref(), &tool, &pending_chunk);
1526        if state.should_stop() {
1527            WalkState::Quit
1528        } else {
1529            WalkState::Continue
1530        }
1531    }
1532}
1533
1534fn build_grep_matcher(pattern: &str) -> anyhow::Result<RegexMatcher> {
1535    let matcher = RegexMatcherBuilder::new()
1536        .line_terminator(Some(b'\n'))
1537        .build(pattern);
1538    match matcher {
1539        Ok(matcher) => Ok(matcher),
1540        Err(_) => RegexMatcherBuilder::new()
1541            .build(pattern)
1542            .map_err(|err| anyhow!(err.to_string())),
1543    }
1544}
1545
1546fn build_grep_searcher() -> grep_searcher::Searcher {
1547    let mut builder = SearcherBuilder::new();
1548    builder
1549        .line_number(true)
1550        // Use ripgrep's auto mmap heuristic as the fast path for read-only workspace search.
1551        .memory_map(unsafe { MmapChoice::auto() })
1552        .binary_detection(BinaryDetection::quit(b'\0'))
1553        .bom_sniffing(false)
1554        .line_terminator(LineTerminator::byte(b'\n'));
1555    builder.build()
1556}
1557
1558#[async_trait]
1559impl Tool for GrepTool {
1560    fn schema(&self) -> ToolSchema {
1561        tool_schema_with_capabilities(
1562            "grep",
1563            "Regex search in files",
1564            json!({"type":"object","properties":{"pattern":{"type":"string"},"path":{"type":"string"}}}),
1565            workspace_search_capabilities(),
1566        )
1567    }
1568    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1569        self.execute_with_cancel(args, CancellationToken::new())
1570            .await
1571    }
1572
1573    async fn execute_with_cancel(
1574        &self,
1575        args: Value,
1576        cancel: CancellationToken,
1577    ) -> anyhow::Result<ToolResult> {
1578        self.execute_with_progress(args, cancel, None).await
1579    }
1580
1581    async fn execute_with_progress(
1582        &self,
1583        args: Value,
1584        cancel: CancellationToken,
1585        progress: Option<SharedToolProgressSink>,
1586    ) -> anyhow::Result<ToolResult> {
1587        let pattern = args["pattern"].as_str().unwrap_or("");
1588        let root = args["path"].as_str().unwrap_or(".");
1589        let Some(root_path) = resolve_walk_root(root, &args) else {
1590            return Ok(sandbox_path_denied_result(root, &args));
1591        };
1592        let matcher = build_grep_matcher(pattern)?;
1593        let state = Arc::new(GrepSearchState::new(
1594            cancel.clone(),
1595            100,
1596            8,
1597            progress.clone(),
1598        ));
1599        let mut builder = GrepParallelVisitorBuilder {
1600            matcher: Arc::new(matcher),
1601            state: Arc::clone(&state),
1602            tool: "grep".to_string(),
1603        };
1604        WalkBuilder::new(&root_path)
1605            .build_parallel()
1606            .visit(&mut builder);
1607        let out = state.sorted_hits();
1608        let limit_reached = out.len() >= 100;
1609        emit_grep_progress_done(
1610            progress.as_ref(),
1611            "grep",
1612            &root_path,
1613            out.len(),
1614            limit_reached,
1615            cancel.is_cancelled(),
1616        );
1617        Ok(ToolResult {
1618            output: out
1619                .iter()
1620                .map(|hit| format!("{}:{}:{}", hit.path, hit.line, hit.text))
1621                .collect::<Vec<_>>()
1622                .join("\n"),
1623            metadata: json!({
1624                "count": out.len(),
1625                "path": root_path.to_string_lossy(),
1626                "truncated": limit_reached,
1627                "cancelled": cancel.is_cancelled(),
1628                "streaming": progress.is_some()
1629            }),
1630        })
1631    }
1632}
1633
1634struct WebFetchTool;
1635#[async_trait]
1636impl Tool for WebFetchTool {
1637    fn schema(&self) -> ToolSchema {
1638        tool_schema_with_capabilities(
1639            "webfetch",
1640            "Fetch URL content and return a structured markdown document",
1641            json!({
1642                "type":"object",
1643                "properties":{
1644                    "url":{"type":"string"},
1645                    "mode":{"type":"string"},
1646                    "return":{"type":"string"},
1647                    "max_bytes":{"type":"integer"},
1648                    "timeout_ms":{"type":"integer"},
1649                    "max_redirects":{"type":"integer"}
1650                }
1651            }),
1652            web_fetch_capabilities(),
1653        )
1654    }
1655    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1656        let url = args["url"].as_str().unwrap_or("").trim();
1657        if url.is_empty() {
1658            return Ok(ToolResult {
1659                output: "url is required".to_string(),
1660                metadata: json!({"url": url}),
1661            });
1662        }
1663        let mode = args["mode"].as_str().unwrap_or("auto");
1664        let return_mode = args["return"].as_str().unwrap_or("markdown");
1665        let timeout_ms = args["timeout_ms"]
1666            .as_u64()
1667            .unwrap_or(15_000)
1668            .clamp(1_000, 120_000);
1669        let max_bytes = args["max_bytes"].as_u64().unwrap_or(500_000).min(5_000_000) as usize;
1670        let max_redirects = args["max_redirects"].as_u64().unwrap_or(5).min(20) as usize;
1671
1672        let started = std::time::Instant::now();
1673        let fetched = fetch_url_with_limits(url, timeout_ms, max_bytes, max_redirects).await?;
1674        let raw = String::from_utf8_lossy(&fetched.buffer).to_string();
1675
1676        let cleaned = strip_html_noise(&raw);
1677        let title = extract_title(&cleaned).unwrap_or_default();
1678        let canonical = extract_canonical(&cleaned);
1679        let links = extract_links(&cleaned);
1680
1681        let markdown = if fetched.content_type.contains("html") || fetched.content_type.is_empty() {
1682            html2md::parse_html(&cleaned)
1683        } else {
1684            cleaned.clone()
1685        };
1686        let text = markdown_to_text(&markdown);
1687
1688        let markdown_out = if return_mode == "text" {
1689            String::new()
1690        } else {
1691            markdown
1692        };
1693        let text_out = if return_mode == "markdown" {
1694            String::new()
1695        } else {
1696            text
1697        };
1698
1699        let raw_chars = raw.chars().count();
1700        let markdown_chars = markdown_out.chars().count();
1701        let reduction_pct = if raw_chars == 0 {
1702            0.0
1703        } else {
1704            ((raw_chars.saturating_sub(markdown_chars)) as f64 / raw_chars as f64) * 100.0
1705        };
1706
1707        let output = json!({
1708            "url": url,
1709            "final_url": fetched.final_url,
1710            "title": title,
1711            "content_type": fetched.content_type,
1712            "markdown": markdown_out,
1713            "text": text_out,
1714            "links": links,
1715            "meta": {
1716                "canonical": canonical,
1717                "mode": mode
1718            },
1719            "stats": {
1720                "bytes_in": fetched.buffer.len(),
1721                "bytes_out": markdown_chars,
1722                "raw_chars": raw_chars,
1723                "markdown_chars": markdown_chars,
1724                "reduction_pct": reduction_pct,
1725                "elapsed_ms": started.elapsed().as_millis(),
1726                "truncated": fetched.truncated
1727            }
1728        });
1729
1730        Ok(ToolResult {
1731            output: serde_json::to_string_pretty(&output)?,
1732            metadata: json!({
1733                "url": url,
1734                "final_url": fetched.final_url,
1735                "content_type": fetched.content_type,
1736                "truncated": fetched.truncated
1737            }),
1738        })
1739    }
1740}
1741
1742struct WebFetchHtmlTool;
1743#[async_trait]
1744impl Tool for WebFetchHtmlTool {
1745    fn schema(&self) -> ToolSchema {
1746        tool_schema_with_capabilities(
1747            "webfetch_html",
1748            "Fetch URL and return raw HTML content",
1749            json!({
1750                "type":"object",
1751                "properties":{
1752                    "url":{"type":"string"},
1753                    "max_bytes":{"type":"integer"},
1754                    "timeout_ms":{"type":"integer"},
1755                    "max_redirects":{"type":"integer"}
1756                }
1757            }),
1758            web_fetch_capabilities(),
1759        )
1760    }
1761    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1762        let url = args["url"].as_str().unwrap_or("").trim();
1763        if url.is_empty() {
1764            return Ok(ToolResult {
1765                output: "url is required".to_string(),
1766                metadata: json!({"url": url}),
1767            });
1768        }
1769        let timeout_ms = args["timeout_ms"]
1770            .as_u64()
1771            .unwrap_or(15_000)
1772            .clamp(1_000, 120_000);
1773        let max_bytes = args["max_bytes"].as_u64().unwrap_or(500_000).min(5_000_000) as usize;
1774        let max_redirects = args["max_redirects"].as_u64().unwrap_or(5).min(20) as usize;
1775
1776        let started = std::time::Instant::now();
1777        let fetched = fetch_url_with_limits(url, timeout_ms, max_bytes, max_redirects).await?;
1778        let output = String::from_utf8_lossy(&fetched.buffer).to_string();
1779
1780        Ok(ToolResult {
1781            output,
1782            metadata: json!({
1783                "url": url,
1784                "final_url": fetched.final_url,
1785                "content_type": fetched.content_type,
1786                "truncated": fetched.truncated,
1787                "bytes_in": fetched.buffer.len(),
1788                "elapsed_ms": started.elapsed().as_millis()
1789            }),
1790        })
1791    }
1792}
1793
1794struct FetchedResponse {
1795    final_url: String,
1796    content_type: String,
1797    buffer: Vec<u8>,
1798    truncated: bool,
1799}
1800
1801async fn fetch_url_with_limits(
1802    url: &str,
1803    timeout_ms: u64,
1804    max_bytes: usize,
1805    max_redirects: usize,
1806) -> anyhow::Result<FetchedResponse> {
1807    let client = reqwest::Client::builder()
1808        .timeout(std::time::Duration::from_millis(timeout_ms))
1809        .redirect(reqwest::redirect::Policy::limited(max_redirects))
1810        .build()?;
1811
1812    let res = client
1813        .get(url)
1814        .header(
1815            "Accept",
1816            "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
1817        )
1818        .send()
1819        .await?;
1820    let final_url = res.url().to_string();
1821    let content_type = res
1822        .headers()
1823        .get("content-type")
1824        .and_then(|v| v.to_str().ok())
1825        .unwrap_or("")
1826        .to_string();
1827
1828    let mut stream = res.bytes_stream();
1829    let mut buffer: Vec<u8> = Vec::new();
1830    let mut truncated = false;
1831    while let Some(chunk) = stream.next().await {
1832        let chunk = chunk?;
1833        if buffer.len() + chunk.len() > max_bytes {
1834            let remaining = max_bytes.saturating_sub(buffer.len());
1835            buffer.extend_from_slice(&chunk[..remaining]);
1836            truncated = true;
1837            break;
1838        }
1839        buffer.extend_from_slice(&chunk);
1840    }
1841
1842    Ok(FetchedResponse {
1843        final_url,
1844        content_type,
1845        buffer,
1846        truncated,
1847    })
1848}
1849
1850fn strip_html_noise(input: &str) -> String {
1851    let script_re = Regex::new(r"(?is)<script[^>]*>.*?</script>").unwrap();
1852    let style_re = Regex::new(r"(?is)<style[^>]*>.*?</style>").unwrap();
1853    let noscript_re = Regex::new(r"(?is)<noscript[^>]*>.*?</noscript>").unwrap();
1854    let cleaned = script_re.replace_all(input, "");
1855    let cleaned = style_re.replace_all(&cleaned, "");
1856    let cleaned = noscript_re.replace_all(&cleaned, "");
1857    cleaned.to_string()
1858}
1859
1860fn extract_title(input: &str) -> Option<String> {
1861    let title_re = Regex::new(r"(?is)<title[^>]*>(.*?)</title>").ok()?;
1862    let caps = title_re.captures(input)?;
1863    let raw = caps.get(1)?.as_str();
1864    let tag_re = Regex::new(r"(?is)<[^>]+>").ok()?;
1865    Some(tag_re.replace_all(raw, "").trim().to_string())
1866}
1867
1868fn extract_canonical(input: &str) -> Option<String> {
1869    let canon_re =
1870        Regex::new(r#"(?is)<link[^>]*rel=["']canonical["'][^>]*href=["']([^"']+)["'][^>]*>"#)
1871            .ok()?;
1872    let caps = canon_re.captures(input)?;
1873    Some(caps.get(1)?.as_str().trim().to_string())
1874}
1875
1876fn extract_links(input: &str) -> Vec<Value> {
1877    let link_re = Regex::new(r#"(?is)<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>"#).unwrap();
1878    let tag_re = Regex::new(r"(?is)<[^>]+>").unwrap();
1879    let mut out = Vec::new();
1880    for caps in link_re.captures_iter(input).take(200) {
1881        let href = caps.get(1).map(|m| m.as_str()).unwrap_or("").trim();
1882        let raw_text = caps.get(2).map(|m| m.as_str()).unwrap_or("");
1883        let text = tag_re.replace_all(raw_text, "");
1884        if !href.is_empty() {
1885            out.push(json!({
1886                "text": text.trim(),
1887                "href": href
1888            }));
1889        }
1890    }
1891    out
1892}
1893
1894fn markdown_to_text(input: &str) -> String {
1895    let code_block_re = Regex::new(r"(?s)```.*?```").unwrap();
1896    let inline_code_re = Regex::new(r"`[^`]*`").unwrap();
1897    let link_re = Regex::new(r"\[([^\]]+)\]\([^)]+\)").unwrap();
1898    let emphasis_re = Regex::new(r"[*_~]+").unwrap();
1899    let cleaned = code_block_re.replace_all(input, "");
1900    let cleaned = inline_code_re.replace_all(&cleaned, "");
1901    let cleaned = link_re.replace_all(&cleaned, "$1");
1902    let cleaned = emphasis_re.replace_all(&cleaned, "");
1903    let cleaned = cleaned.replace('#', "");
1904    let whitespace_re = Regex::new(r"\n{3,}").unwrap();
1905    let cleaned = whitespace_re.replace_all(&cleaned, "\n\n");
1906    cleaned.trim().to_string()
1907}
1908
1909struct McpDebugTool;
1910#[async_trait]
1911impl Tool for McpDebugTool {
1912    fn schema(&self) -> ToolSchema {
1913        tool_schema(
1914            "mcp_debug",
1915            "Call an MCP tool and return the raw response",
1916            json!({
1917                "type":"object",
1918                "properties":{
1919                    "url":{"type":"string"},
1920                    "tool":{"type":"string"},
1921                    "args":{"type":"object"},
1922                    "headers":{"type":"object"},
1923                    "timeout_ms":{"type":"integer"},
1924                    "max_bytes":{"type":"integer"}
1925                }
1926            }),
1927        )
1928    }
1929    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1930        let url = args["url"].as_str().unwrap_or("").trim();
1931        let tool = args["tool"].as_str().unwrap_or("").trim();
1932        if url.is_empty() || tool.is_empty() {
1933            return Ok(ToolResult {
1934                output: "url and tool are required".to_string(),
1935                metadata: json!({"url": url, "tool": tool}),
1936            });
1937        }
1938        let timeout_ms = args["timeout_ms"]
1939            .as_u64()
1940            .unwrap_or(15_000)
1941            .clamp(1_000, 120_000);
1942        let max_bytes = args["max_bytes"].as_u64().unwrap_or(200_000).min(5_000_000) as usize;
1943        let request_args = args.get("args").cloned().unwrap_or_else(|| json!({}));
1944
1945        #[derive(serde::Serialize)]
1946        struct McpCallRequest {
1947            jsonrpc: String,
1948            id: u32,
1949            method: String,
1950            params: McpCallParams,
1951        }
1952
1953        #[derive(serde::Serialize)]
1954        struct McpCallParams {
1955            name: String,
1956            arguments: Value,
1957        }
1958
1959        let request = McpCallRequest {
1960            jsonrpc: "2.0".to_string(),
1961            id: 1,
1962            method: "tools/call".to_string(),
1963            params: McpCallParams {
1964                name: tool.to_string(),
1965                arguments: request_args,
1966            },
1967        };
1968
1969        let client = reqwest::Client::builder()
1970            .timeout(std::time::Duration::from_millis(timeout_ms))
1971            .build()?;
1972
1973        let mut builder = client
1974            .post(url)
1975            .header("Content-Type", "application/json")
1976            .header("Accept", "application/json, text/event-stream");
1977
1978        if let Some(headers) = args.get("headers").and_then(|v| v.as_object()) {
1979            for (key, value) in headers {
1980                if let Some(value) = value.as_str() {
1981                    builder = builder.header(key, value);
1982                }
1983            }
1984        }
1985
1986        let res = builder.json(&request).send().await?;
1987        let status = res.status().as_u16();
1988
1989        let mut response_headers = serde_json::Map::new();
1990        for (key, value) in res.headers().iter() {
1991            if let Ok(value) = value.to_str() {
1992                response_headers.insert(key.to_string(), Value::String(value.to_string()));
1993            }
1994        }
1995
1996        let mut stream = res.bytes_stream();
1997        let mut buffer: Vec<u8> = Vec::new();
1998        let mut truncated = false;
1999
2000        while let Some(chunk) = stream.next().await {
2001            let chunk = chunk?;
2002            if buffer.len() + chunk.len() > max_bytes {
2003                let remaining = max_bytes.saturating_sub(buffer.len());
2004                buffer.extend_from_slice(&chunk[..remaining]);
2005                truncated = true;
2006                break;
2007            }
2008            buffer.extend_from_slice(&chunk);
2009        }
2010
2011        let body = String::from_utf8_lossy(&buffer).to_string();
2012        let output = json!({
2013            "status": status,
2014            "headers": response_headers,
2015            "body": body,
2016            "truncated": truncated,
2017            "bytes": buffer.len()
2018        });
2019
2020        Ok(ToolResult {
2021            output: serde_json::to_string_pretty(&output)?,
2022            metadata: json!({
2023                "url": url,
2024                "tool": tool,
2025                "timeout_ms": timeout_ms,
2026                "max_bytes": max_bytes
2027            }),
2028        })
2029    }
2030}
2031
2032struct WebSearchTool {
2033    backend: SearchBackend,
2034}
2035#[async_trait]
2036impl Tool for WebSearchTool {
2037    fn schema(&self) -> ToolSchema {
2038        tool_schema_with_capabilities(
2039            "websearch",
2040            self.backend.schema_description(),
2041            json!({
2042                "type": "object",
2043                "properties": {
2044                    "query": { "type": "string" },
2045                    "limit": { "type": "integer" }
2046                },
2047                "required": ["query"]
2048            }),
2049            web_fetch_capabilities(),
2050        )
2051    }
2052    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
2053        let query = extract_websearch_query(&args).unwrap_or_default();
2054        let query_source = args
2055            .get("__query_source")
2056            .and_then(|v| v.as_str())
2057            .map(|s| s.to_string())
2058            .unwrap_or_else(|| {
2059                if query.is_empty() {
2060                    "missing".to_string()
2061                } else {
2062                    "tool_args".to_string()
2063                }
2064            });
2065        let query_hash = if query.is_empty() {
2066            None
2067        } else {
2068            Some(stable_hash(&query))
2069        };
2070        if query.is_empty() {
2071            tracing::warn!("WebSearchTool missing query. Args: {}", args);
2072            return Ok(ToolResult {
2073                output: format!("missing query. Received args: {}", args),
2074                metadata: json!({
2075                    "count": 0,
2076                    "error": "missing_query",
2077                    "query_source": query_source,
2078                    "query_hash": query_hash,
2079                    "loop_guard_triggered": false
2080                }),
2081            });
2082        }
2083        let num_results = extract_websearch_limit(&args).unwrap_or(8);
2084        let outcome = execute_websearch_backend(&self.backend, &query, num_results).await?;
2085        let configured_backend = self.backend.name();
2086        let backend_used = outcome
2087            .backend_used
2088            .as_deref()
2089            .unwrap_or(configured_backend);
2090        let mut metadata = json!({
2091            "query": query,
2092            "query_source": query_source,
2093            "query_hash": query_hash,
2094            "backend": backend_used,
2095            "configured_backend": configured_backend,
2096            "attempted_backends": outcome.attempted_backends,
2097            "loop_guard_triggered": false,
2098            "count": outcome.results.len(),
2099            "partial": outcome.partial
2100        });
2101        if let Some(kind) = outcome.unavailable_kind {
2102            metadata["error"] = json!(kind);
2103        }
2104
2105        if let Some(message) = outcome.unavailable_message {
2106            return Ok(ToolResult {
2107                output: message,
2108                metadata: metadata,
2109            });
2110        }
2111
2112        let output = json!({
2113            "query": query,
2114            "backend": backend_used,
2115            "configured_backend": configured_backend,
2116            "attempted_backends": metadata["attempted_backends"],
2117            "result_count": outcome.results.len(),
2118            "partial": outcome.partial,
2119            "results": outcome.results,
2120        });
2121
2122        Ok(ToolResult {
2123            output: serde_json::to_string_pretty(&output)?,
2124            metadata,
2125        })
2126    }
2127}
2128
2129struct SearchExecutionOutcome {
2130    results: Vec<SearchResultEntry>,
2131    partial: bool,
2132    unavailable_message: Option<String>,
2133    unavailable_kind: Option<&'static str>,
2134    backend_used: Option<String>,
2135    attempted_backends: Vec<String>,
2136}
2137
2138async fn execute_websearch_backend(
2139    backend: &SearchBackend,
2140    query: &str,
2141    num_results: u64,
2142) -> anyhow::Result<SearchExecutionOutcome> {
2143    match backend {
2144        SearchBackend::Auto { backends } => {
2145            let mut attempted_backends = Vec::new();
2146            let mut best_unavailable: Option<SearchExecutionOutcome> = None;
2147
2148            for candidate in backends {
2149                let mut outcome =
2150                    execute_websearch_backend_once(candidate, query, num_results).await?;
2151                attempted_backends.extend(outcome.attempted_backends.iter().cloned());
2152                if outcome.unavailable_kind.is_none() {
2153                    if outcome.backend_used.is_none() {
2154                        outcome.backend_used = Some(candidate.name().to_string());
2155                    }
2156                    outcome.attempted_backends = attempted_backends;
2157                    return Ok(outcome);
2158                }
2159
2160                let should_replace = best_unavailable
2161                    .as_ref()
2162                    .map(|current| {
2163                        search_unavailability_priority(outcome.unavailable_kind)
2164                            > search_unavailability_priority(current.unavailable_kind)
2165                    })
2166                    .unwrap_or(true);
2167                outcome.attempted_backends = attempted_backends.clone();
2168                if should_replace {
2169                    best_unavailable = Some(outcome);
2170                }
2171            }
2172
2173            let mut outcome = best_unavailable.unwrap_or_else(search_backend_unavailable_outcome);
2174            outcome.attempted_backends = attempted_backends;
2175            Ok(outcome)
2176        }
2177        _ => execute_websearch_backend_once(backend, query, num_results).await,
2178    }
2179}
2180
2181async fn execute_websearch_backend_once(
2182    backend: &SearchBackend,
2183    query: &str,
2184    num_results: u64,
2185) -> anyhow::Result<SearchExecutionOutcome> {
2186    match backend {
2187        SearchBackend::Disabled { reason } => Ok(SearchExecutionOutcome {
2188            results: Vec::new(),
2189            partial: false,
2190            unavailable_message: Some(format!(
2191                "Search backend is unavailable for `websearch`: {reason}"
2192            )),
2193            unavailable_kind: Some("backend_unavailable"),
2194            backend_used: Some("disabled".to_string()),
2195            attempted_backends: vec!["disabled".to_string()],
2196        }),
2197        SearchBackend::Tandem {
2198            base_url,
2199            timeout_ms,
2200        } => execute_tandem_search(base_url, *timeout_ms, query, num_results).await,
2201        SearchBackend::Searxng {
2202            base_url,
2203            engines,
2204            timeout_ms,
2205        } => {
2206            execute_searxng_search(
2207                base_url,
2208                engines.as_deref(),
2209                *timeout_ms,
2210                query,
2211                num_results,
2212            )
2213            .await
2214        }
2215        SearchBackend::Exa {
2216            api_key,
2217            timeout_ms,
2218        } => execute_exa_search(api_key, *timeout_ms, query, num_results).await,
2219        SearchBackend::Brave {
2220            api_key,
2221            timeout_ms,
2222        } => execute_brave_search(api_key, *timeout_ms, query, num_results).await,
2223        SearchBackend::Auto { .. } => unreachable!("auto backend should be handled by the wrapper"),
2224    }
2225}
2226
2227fn search_backend_unavailable_outcome() -> SearchExecutionOutcome {
2228    SearchExecutionOutcome {
2229        results: Vec::new(),
2230        partial: false,
2231        unavailable_message: Some(
2232            "Web search is currently unavailable for `websearch`.\nContinue with local file reads and note that external research could not be completed in this run."
2233                .to_string(),
2234        ),
2235        unavailable_kind: Some("backend_unavailable"),
2236        backend_used: None,
2237        attempted_backends: Vec::new(),
2238    }
2239}
2240
2241fn search_backend_authorization_required_outcome() -> SearchExecutionOutcome {
2242    SearchExecutionOutcome {
2243        results: Vec::new(),
2244        partial: false,
2245        unavailable_message: Some(
2246            "Authorization required for `websearch`.\nThis integration requires authorization before this action can run."
2247                .to_string(),
2248        ),
2249        unavailable_kind: Some("authorization_required"),
2250        backend_used: None,
2251        attempted_backends: Vec::new(),
2252    }
2253}
2254
2255fn search_backend_rate_limited_outcome(
2256    backend_name: &str,
2257    retry_after_secs: Option<u64>,
2258) -> SearchExecutionOutcome {
2259    let retry_hint = retry_after_secs
2260        .map(|value| format!("\nRetry after about {value} second(s)."))
2261        .unwrap_or_default();
2262    SearchExecutionOutcome {
2263        results: Vec::new(),
2264        partial: false,
2265        unavailable_message: Some(format!(
2266            "Web search is currently rate limited for `websearch` on the {backend_name} backend.\nContinue with local file reads and note that external research could not be completed in this run.{retry_hint}"
2267        )),
2268        unavailable_kind: Some("rate_limited"),
2269        backend_used: Some(backend_name.to_string()),
2270        attempted_backends: vec![backend_name.to_string()],
2271    }
2272}
2273
2274fn search_unavailability_priority(kind: Option<&'static str>) -> u8 {
2275    match kind {
2276        Some("authorization_required") => 3,
2277        Some("rate_limited") => 2,
2278        Some("backend_unavailable") => 1,
2279        _ => 0,
2280    }
2281}
2282
2283async fn execute_tandem_search(
2284    base_url: &str,
2285    timeout_ms: u64,
2286    query: &str,
2287    num_results: u64,
2288) -> anyhow::Result<SearchExecutionOutcome> {
2289    let client = reqwest::Client::builder()
2290        .timeout(Duration::from_millis(timeout_ms))
2291        .build()?;
2292    let endpoint = format!("{}/search", base_url.trim_end_matches('/'));
2293    let response = match client
2294        .post(&endpoint)
2295        .header("Content-Type", "application/json")
2296        .header("Accept", "application/json")
2297        .json(&json!({
2298            "query": query,
2299            "limit": num_results,
2300        }))
2301        .send()
2302        .await
2303    {
2304        Ok(response) => response,
2305        Err(_) => {
2306            let mut outcome = search_backend_unavailable_outcome();
2307            outcome.backend_used = Some("tandem".to_string());
2308            outcome.attempted_backends = vec!["tandem".to_string()];
2309            return Ok(outcome);
2310        }
2311    };
2312    let status = response.status();
2313    if matches!(
2314        status,
2315        reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
2316    ) {
2317        let mut outcome = search_backend_authorization_required_outcome();
2318        outcome.backend_used = Some("tandem".to_string());
2319        outcome.attempted_backends = vec!["tandem".to_string()];
2320        return Ok(outcome);
2321    }
2322    if !status.is_success() {
2323        let mut outcome = search_backend_unavailable_outcome();
2324        outcome.backend_used = Some("tandem".to_string());
2325        outcome.attempted_backends = vec!["tandem".to_string()];
2326        return Ok(outcome);
2327    }
2328    let body: Value = match response.json().await {
2329        Ok(value) => value,
2330        Err(_) => {
2331            let mut outcome = search_backend_unavailable_outcome();
2332            outcome.backend_used = Some("tandem".to_string());
2333            outcome.attempted_backends = vec!["tandem".to_string()];
2334            return Ok(outcome);
2335        }
2336    };
2337    let raw_results = body
2338        .get("results")
2339        .and_then(Value::as_array)
2340        .cloned()
2341        .unwrap_or_default();
2342    let results = normalize_tandem_results(&raw_results, num_results as usize);
2343    let partial = body
2344        .get("partial")
2345        .and_then(Value::as_bool)
2346        .unwrap_or_else(|| raw_results.len() > results.len());
2347    Ok(SearchExecutionOutcome {
2348        results,
2349        partial,
2350        unavailable_message: None,
2351        unavailable_kind: None,
2352        backend_used: Some("tandem".to_string()),
2353        attempted_backends: vec!["tandem".to_string()],
2354    })
2355}
2356
2357async fn execute_searxng_search(
2358    base_url: &str,
2359    engines: Option<&str>,
2360    timeout_ms: u64,
2361    query: &str,
2362    num_results: u64,
2363) -> anyhow::Result<SearchExecutionOutcome> {
2364    let client = reqwest::Client::builder()
2365        .timeout(Duration::from_millis(timeout_ms))
2366        .build()?;
2367    let endpoint = format!("{}/search", base_url.trim_end_matches('/'));
2368    let mut params: Vec<(&str, String)> = vec![
2369        ("q", query.to_string()),
2370        ("format", "json".to_string()),
2371        ("pageno", "1".to_string()),
2372    ];
2373    if let Some(engines) = engines {
2374        params.push(("engines", engines.to_string()));
2375    }
2376    let response = client.get(&endpoint).query(&params).send().await?;
2377
2378    let status = response.status();
2379    if status == reqwest::StatusCode::FORBIDDEN {
2380        let mut outcome = search_backend_authorization_required_outcome();
2381        outcome.backend_used = Some("searxng".to_string());
2382        outcome.attempted_backends = vec!["searxng".to_string()];
2383        return Ok(outcome);
2384    }
2385    if !status.is_success() {
2386        let mut outcome = search_backend_unavailable_outcome();
2387        outcome.backend_used = Some("searxng".to_string());
2388        outcome.attempted_backends = vec!["searxng".to_string()];
2389        return Ok(outcome);
2390    }
2391    let status_for_error = status;
2392    let body: Value = match response.json().await {
2393        Ok(value) => value,
2394        Err(_) => {
2395            let mut outcome = search_backend_unavailable_outcome();
2396            outcome.backend_used = Some("searxng".to_string());
2397            outcome.attempted_backends = vec!["searxng".to_string()];
2398            return Ok(outcome);
2399        }
2400    };
2401    let raw_results = body
2402        .get("results")
2403        .and_then(Value::as_array)
2404        .cloned()
2405        .unwrap_or_default();
2406    let results = normalize_searxng_results(&raw_results, num_results as usize);
2407    let partial = raw_results.len() > results.len()
2408        || status_for_error == reqwest::StatusCode::PARTIAL_CONTENT;
2409    Ok(SearchExecutionOutcome {
2410        results,
2411        partial,
2412        unavailable_message: None,
2413        unavailable_kind: None,
2414        backend_used: Some("searxng".to_string()),
2415        attempted_backends: vec!["searxng".to_string()],
2416    })
2417}
2418
2419async fn execute_exa_search(
2420    api_key: &str,
2421    timeout_ms: u64,
2422    query: &str,
2423    num_results: u64,
2424) -> anyhow::Result<SearchExecutionOutcome> {
2425    let client = reqwest::Client::builder()
2426        .timeout(Duration::from_millis(timeout_ms))
2427        .build()?;
2428    let response = match client
2429        .post("https://api.exa.ai/search")
2430        .header("Content-Type", "application/json")
2431        .header("Accept", "application/json")
2432        .header("x-api-key", api_key)
2433        .json(&json!({
2434            "query": query,
2435            "numResults": num_results,
2436        }))
2437        .send()
2438        .await
2439    {
2440        Ok(response) => response,
2441        Err(_) => {
2442            let mut outcome = search_backend_unavailable_outcome();
2443            outcome.backend_used = Some("exa".to_string());
2444            outcome.attempted_backends = vec!["exa".to_string()];
2445            return Ok(outcome);
2446        }
2447    };
2448    let status = response.status();
2449    if matches!(
2450        status,
2451        reqwest::StatusCode::UNAUTHORIZED
2452            | reqwest::StatusCode::FORBIDDEN
2453            | reqwest::StatusCode::PAYMENT_REQUIRED
2454    ) {
2455        let mut outcome = search_backend_authorization_required_outcome();
2456        outcome.backend_used = Some("exa".to_string());
2457        outcome.attempted_backends = vec!["exa".to_string()];
2458        return Ok(outcome);
2459    }
2460    if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
2461        let retry_after_secs = response
2462            .headers()
2463            .get("retry-after")
2464            .and_then(|value| value.to_str().ok())
2465            .and_then(|value| value.trim().parse::<u64>().ok());
2466        return Ok(search_backend_rate_limited_outcome("exa", retry_after_secs));
2467    }
2468    if !status.is_success() {
2469        let mut outcome = search_backend_unavailable_outcome();
2470        outcome.backend_used = Some("exa".to_string());
2471        outcome.attempted_backends = vec!["exa".to_string()];
2472        return Ok(outcome);
2473    }
2474    let body: Value = match response.json().await {
2475        Ok(value) => value,
2476        Err(_) => {
2477            let mut outcome = search_backend_unavailable_outcome();
2478            outcome.backend_used = Some("exa".to_string());
2479            outcome.attempted_backends = vec!["exa".to_string()];
2480            return Ok(outcome);
2481        }
2482    };
2483    let raw_results = body
2484        .get("results")
2485        .and_then(Value::as_array)
2486        .cloned()
2487        .unwrap_or_default();
2488    let results = normalize_exa_results(&raw_results, num_results as usize);
2489    Ok(SearchExecutionOutcome {
2490        partial: raw_results.len() > results.len(),
2491        results,
2492        unavailable_message: None,
2493        unavailable_kind: None,
2494        backend_used: Some("exa".to_string()),
2495        attempted_backends: vec!["exa".to_string()],
2496    })
2497}
2498
2499async fn execute_brave_search(
2500    api_key: &str,
2501    timeout_ms: u64,
2502    query: &str,
2503    num_results: u64,
2504) -> anyhow::Result<SearchExecutionOutcome> {
2505    let client = reqwest::Client::builder()
2506        .timeout(Duration::from_millis(timeout_ms))
2507        .build()?;
2508    let count = num_results.to_string();
2509    let response = match client
2510        .get("https://api.search.brave.com/res/v1/web/search")
2511        .header("Accept", "application/json")
2512        .header("X-Subscription-Token", api_key)
2513        .query(&[("q", query), ("count", count.as_str())])
2514        .send()
2515        .await
2516    {
2517        Ok(response) => response,
2518        Err(error) => {
2519            tracing::warn!("brave websearch request failed: {}", error);
2520            let mut outcome = search_backend_unavailable_outcome();
2521            outcome.backend_used = Some("brave".to_string());
2522            outcome.attempted_backends = vec!["brave".to_string()];
2523            return Ok(outcome);
2524        }
2525    };
2526    let status = response.status();
2527    if matches!(
2528        status,
2529        reqwest::StatusCode::UNAUTHORIZED
2530            | reqwest::StatusCode::FORBIDDEN
2531            | reqwest::StatusCode::PAYMENT_REQUIRED
2532    ) {
2533        let mut outcome = search_backend_authorization_required_outcome();
2534        outcome.backend_used = Some("brave".to_string());
2535        outcome.attempted_backends = vec!["brave".to_string()];
2536        return Ok(outcome);
2537    }
2538    if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
2539        let retry_after_secs = response
2540            .headers()
2541            .get("retry-after")
2542            .and_then(|value| value.to_str().ok())
2543            .and_then(|value| value.trim().parse::<u64>().ok());
2544        return Ok(search_backend_rate_limited_outcome(
2545            "brave",
2546            retry_after_secs,
2547        ));
2548    }
2549    if !status.is_success() {
2550        tracing::warn!("brave websearch returned non-success status: {}", status);
2551        let mut outcome = search_backend_unavailable_outcome();
2552        outcome.backend_used = Some("brave".to_string());
2553        outcome.attempted_backends = vec!["brave".to_string()];
2554        return Ok(outcome);
2555    }
2556    let body_text = match response.text().await {
2557        Ok(value) => value,
2558        Err(error) => {
2559            tracing::warn!("brave websearch body read failed: {}", error);
2560            let mut outcome = search_backend_unavailable_outcome();
2561            outcome.backend_used = Some("brave".to_string());
2562            outcome.attempted_backends = vec!["brave".to_string()];
2563            return Ok(outcome);
2564        }
2565    };
2566    let body: Value = match serde_json::from_str(&body_text) {
2567        Ok(value) => value,
2568        Err(error) => {
2569            let snippet = body_text.chars().take(200).collect::<String>();
2570            tracing::warn!(
2571                "brave websearch JSON parse failed: {} body_prefix={:?}",
2572                error,
2573                snippet
2574            );
2575            let mut outcome = search_backend_unavailable_outcome();
2576            outcome.backend_used = Some("brave".to_string());
2577            outcome.attempted_backends = vec!["brave".to_string()];
2578            return Ok(outcome);
2579        }
2580    };
2581    let raw_results = body
2582        .get("web")
2583        .and_then(|value| value.get("results"))
2584        .and_then(Value::as_array)
2585        .cloned()
2586        .unwrap_or_default();
2587    let results = normalize_brave_results(&raw_results, num_results as usize);
2588    Ok(SearchExecutionOutcome {
2589        partial: raw_results.len() > results.len(),
2590        results,
2591        unavailable_message: None,
2592        unavailable_kind: None,
2593        backend_used: Some("brave".to_string()),
2594        attempted_backends: vec!["brave".to_string()],
2595    })
2596}
2597
2598fn normalize_tandem_results(raw_results: &[Value], limit: usize) -> Vec<SearchResultEntry> {
2599    raw_results
2600        .iter()
2601        .filter_map(|entry| {
2602            let title = entry
2603                .get("title")
2604                .or_else(|| entry.get("name"))
2605                .and_then(Value::as_str)?
2606                .trim()
2607                .to_string();
2608            let url = entry.get("url").and_then(Value::as_str)?.trim().to_string();
2609            if title.is_empty() || url.is_empty() {
2610                return None;
2611            }
2612            let snippet = entry
2613                .get("snippet")
2614                .or_else(|| entry.get("content"))
2615                .or_else(|| entry.get("description"))
2616                .and_then(Value::as_str)
2617                .map(str::trim)
2618                .unwrap_or_default()
2619                .to_string();
2620            let source = entry
2621                .get("source")
2622                .or_else(|| entry.get("provider"))
2623                .and_then(Value::as_str)
2624                .map(str::trim)
2625                .filter(|value| !value.is_empty())
2626                .unwrap_or("tandem")
2627                .to_string();
2628            Some(SearchResultEntry {
2629                title,
2630                url,
2631                snippet,
2632                source,
2633            })
2634        })
2635        .take(limit)
2636        .collect()
2637}
2638
2639fn normalize_searxng_results(raw_results: &[Value], limit: usize) -> Vec<SearchResultEntry> {
2640    raw_results
2641        .iter()
2642        .filter_map(|entry| {
2643            let title = entry
2644                .get("title")
2645                .and_then(Value::as_str)?
2646                .trim()
2647                .to_string();
2648            let url = entry.get("url").and_then(Value::as_str)?.trim().to_string();
2649            if title.is_empty() || url.is_empty() {
2650                return None;
2651            }
2652            let snippet = entry
2653                .get("content")
2654                .and_then(Value::as_str)
2655                .or_else(|| entry.get("snippet").and_then(Value::as_str))
2656                .unwrap_or("")
2657                .trim()
2658                .to_string();
2659            let source = entry
2660                .get("engine")
2661                .and_then(Value::as_str)
2662                .map(|engine| format!("searxng:{engine}"))
2663                .unwrap_or_else(|| "searxng".to_string());
2664            Some(SearchResultEntry {
2665                title,
2666                url,
2667                snippet,
2668                source,
2669            })
2670        })
2671        .take(limit)
2672        .collect()
2673}
2674
2675fn normalize_exa_results(raw_results: &[Value], limit: usize) -> Vec<SearchResultEntry> {
2676    raw_results
2677        .iter()
2678        .filter_map(|entry| {
2679            let title = entry
2680                .get("title")
2681                .and_then(Value::as_str)?
2682                .trim()
2683                .to_string();
2684            let url = entry.get("url").and_then(Value::as_str)?.trim().to_string();
2685            if title.is_empty() || url.is_empty() {
2686                return None;
2687            }
2688            let snippet = entry
2689                .get("text")
2690                .and_then(Value::as_str)
2691                .or_else(|| {
2692                    entry
2693                        .get("highlights")
2694                        .and_then(Value::as_array)
2695                        .and_then(|items| items.iter().find_map(Value::as_str))
2696                })
2697                .unwrap_or("")
2698                .chars()
2699                .take(400)
2700                .collect::<String>()
2701                .trim()
2702                .to_string();
2703            Some(SearchResultEntry {
2704                title,
2705                url,
2706                snippet,
2707                source: "exa".to_string(),
2708            })
2709        })
2710        .take(limit)
2711        .collect()
2712}
2713
2714fn normalize_brave_results(raw_results: &[Value], limit: usize) -> Vec<SearchResultEntry> {
2715    raw_results
2716        .iter()
2717        .filter_map(|entry| {
2718            let title = entry
2719                .get("title")
2720                .and_then(Value::as_str)?
2721                .trim()
2722                .to_string();
2723            let url = entry.get("url").and_then(Value::as_str)?.trim().to_string();
2724            if title.is_empty() || url.is_empty() {
2725                return None;
2726            }
2727            let snippet = entry
2728                .get("description")
2729                .and_then(Value::as_str)
2730                .or_else(|| entry.get("snippet").and_then(Value::as_str))
2731                .unwrap_or("")
2732                .trim()
2733                .to_string();
2734            let source = entry
2735                .get("profile")
2736                .and_then(|value| value.get("long_name"))
2737                .and_then(Value::as_str)
2738                .map(|value| format!("brave:{value}"))
2739                .unwrap_or_else(|| "brave".to_string());
2740            Some(SearchResultEntry {
2741                title,
2742                url,
2743                snippet,
2744                source,
2745            })
2746        })
2747        .take(limit)
2748        .collect()
2749}
2750
2751fn stable_hash(input: &str) -> String {
2752    let mut hasher = DefaultHasher::new();
2753    input.hash(&mut hasher);
2754    format!("{:016x}", hasher.finish())
2755}
2756
2757fn extract_websearch_query(args: &Value) -> Option<String> {
2758    // Direct keys first.
2759    const QUERY_KEYS: [&str; 5] = ["query", "q", "search_query", "searchQuery", "keywords"];
2760    for key in QUERY_KEYS {
2761        if let Some(query) = args.get(key).and_then(|v| v.as_str()) {
2762            if let Some(cleaned) = sanitize_websearch_query_candidate(query) {
2763                return Some(cleaned);
2764            }
2765        }
2766    }
2767
2768    // Some tool-call envelopes nest args.
2769    for container in ["arguments", "args", "input", "params"] {
2770        if let Some(obj) = args.get(container) {
2771            for key in QUERY_KEYS {
2772                if let Some(query) = obj.get(key).and_then(|v| v.as_str()) {
2773                    if let Some(cleaned) = sanitize_websearch_query_candidate(query) {
2774                        return Some(cleaned);
2775                    }
2776                }
2777            }
2778        }
2779    }
2780
2781    // Last resort: plain string args.
2782    args.as_str().and_then(sanitize_websearch_query_candidate)
2783}
2784
2785fn sanitize_websearch_query_candidate(raw: &str) -> Option<String> {
2786    let trimmed = raw.trim();
2787    if trimmed.is_empty() {
2788        return None;
2789    }
2790
2791    let lower = trimmed.to_ascii_lowercase();
2792    if let Some(start) = lower.find("<arg_value>") {
2793        let value_start = start + "<arg_value>".len();
2794        let tail = &trimmed[value_start..];
2795        let value = if let Some(end) = tail.to_ascii_lowercase().find("</arg_value>") {
2796            &tail[..end]
2797        } else {
2798            tail
2799        };
2800        let cleaned = value.trim();
2801        if !cleaned.is_empty() {
2802            return Some(cleaned.to_string());
2803        }
2804    }
2805
2806    let without_wrappers = trimmed
2807        .replace("<arg_key>", " ")
2808        .replace("</arg_key>", " ")
2809        .replace("<arg_value>", " ")
2810        .replace("</arg_value>", " ");
2811    let collapsed = without_wrappers
2812        .split_whitespace()
2813        .collect::<Vec<_>>()
2814        .join(" ");
2815    if collapsed.is_empty() {
2816        return None;
2817    }
2818
2819    let collapsed_lower = collapsed.to_ascii_lowercase();
2820    if let Some(rest) = collapsed_lower.strip_prefix("websearch query ") {
2821        let offset = collapsed.len() - rest.len();
2822        let q = collapsed[offset..].trim();
2823        if !q.is_empty() {
2824            return Some(q.to_string());
2825        }
2826    }
2827    if let Some(rest) = collapsed_lower.strip_prefix("query ") {
2828        let offset = collapsed.len() - rest.len();
2829        let q = collapsed[offset..].trim();
2830        if !q.is_empty() {
2831            return Some(q.to_string());
2832        }
2833    }
2834
2835    Some(collapsed)
2836}
2837
2838fn extract_websearch_limit(args: &Value) -> Option<u64> {
2839    let mut read_limit = |value: &Value| value.as_u64().map(|v| v.clamp(1, 10));
2840
2841    if let Some(limit) = args
2842        .get("limit")
2843        .and_then(&mut read_limit)
2844        .or_else(|| args.get("numResults").and_then(&mut read_limit))
2845        .or_else(|| args.get("num_results").and_then(&mut read_limit))
2846    {
2847        return Some(limit);
2848    }
2849
2850    for container in ["arguments", "args", "input", "params"] {
2851        if let Some(obj) = args.get(container) {
2852            if let Some(limit) = obj
2853                .get("limit")
2854                .and_then(&mut read_limit)
2855                .or_else(|| obj.get("numResults").and_then(&mut read_limit))
2856                .or_else(|| obj.get("num_results").and_then(&mut read_limit))
2857            {
2858                return Some(limit);
2859            }
2860        }
2861    }
2862    None
2863}
2864
2865struct CodeSearchTool;
2866#[async_trait]
2867impl Tool for CodeSearchTool {
2868    fn schema(&self) -> ToolSchema {
2869        tool_schema_with_capabilities(
2870            "codesearch",
2871            "Search code in workspace files",
2872            json!({"type":"object","properties":{"query":{"type":"string"},"path":{"type":"string"},"limit":{"type":"integer"}}}),
2873            workspace_search_capabilities(),
2874        )
2875    }
2876    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
2877        let query = args["query"].as_str().unwrap_or("").trim();
2878        if query.is_empty() {
2879            return Ok(ToolResult {
2880                output: "missing query".to_string(),
2881                metadata: json!({"count": 0}),
2882            });
2883        }
2884        let root = args["path"].as_str().unwrap_or(".");
2885        let Some(root_path) = resolve_walk_root(root, &args) else {
2886            return Ok(sandbox_path_denied_result(root, &args));
2887        };
2888        let limit = args["limit"]
2889            .as_u64()
2890            .map(|v| v.clamp(1, 200) as usize)
2891            .unwrap_or(50);
2892        let mut hits = Vec::new();
2893        let lower = query.to_lowercase();
2894        for entry in WalkBuilder::new(&root_path).build().flatten() {
2895            if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
2896                continue;
2897            }
2898            let path = entry.path();
2899            let ext = path.extension().and_then(|v| v.to_str()).unwrap_or("");
2900            if !matches!(
2901                ext,
2902                "rs" | "ts" | "tsx" | "js" | "jsx" | "py" | "md" | "toml" | "json"
2903            ) {
2904                continue;
2905            }
2906            if let Ok(content) = fs::read_to_string(path).await {
2907                for (idx, line) in content.lines().enumerate() {
2908                    if line.to_lowercase().contains(&lower) {
2909                        hits.push(format!("{}:{}:{}", path.display(), idx + 1, line.trim()));
2910                        if hits.len() >= limit {
2911                            break;
2912                        }
2913                    }
2914                }
2915            }
2916            if hits.len() >= limit {
2917                break;
2918            }
2919        }
2920        Ok(ToolResult {
2921            output: hits.join("\n"),
2922            metadata: json!({"count": hits.len(), "query": query, "path": root_path.to_string_lossy()}),
2923        })
2924    }
2925}
2926
2927struct TodoWriteTool;
2928#[async_trait]
2929impl Tool for TodoWriteTool {
2930    fn schema(&self) -> ToolSchema {
2931        tool_schema(
2932            "todo_write",
2933            "Update todo list",
2934            json!({
2935                "type":"object",
2936                "properties":{
2937                    "todos":{
2938                        "type":"array",
2939                        "items":{
2940                            "type":"object",
2941                            "properties":{
2942                                "id":{"type":"string"},
2943                                "content":{"type":"string"},
2944                                "text":{"type":"string"},
2945                                "status":{"type":"string"}
2946                            }
2947                        }
2948                    }
2949                }
2950            }),
2951        )
2952    }
2953    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
2954        let todos = normalize_todos(args["todos"].as_array().cloned().unwrap_or_default());
2955        Ok(ToolResult {
2956            output: format!("todo list updated: {} items", todos.len()),
2957            metadata: json!({"todos": todos}),
2958        })
2959    }
2960}
2961
2962struct TaskTool;
2963#[async_trait]
2964impl Tool for TaskTool {
2965    fn schema(&self) -> ToolSchema {
2966        tool_schema(
2967            "task",
2968            "Create a subtask summary or spawn a teammate when team_name is provided.",
2969            task_schema(),
2970        )
2971    }
2972    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
2973        let input = serde_json::from_value::<TaskInput>(args.clone())
2974            .map_err(|err| anyhow!("invalid Task args: {}", err))?;
2975        let description = input.description;
2976        if let Some(team_name_raw) = input.team_name {
2977            let team_name = sanitize_team_name(&team_name_raw)?;
2978            let paths = resolve_agent_team_paths(&args)?;
2979            fs::create_dir_all(paths.team_dir(&team_name)).await?;
2980            fs::create_dir_all(paths.tasks_dir(&team_name)).await?;
2981            fs::create_dir_all(paths.mailboxes_dir(&team_name)).await?;
2982            fs::create_dir_all(paths.requests_dir(&team_name)).await?;
2983            upsert_team_index(&paths, &team_name).await?;
2984
2985            let member_name = if let Some(requested_name) = input.name {
2986                sanitize_member_name(&requested_name)?
2987            } else {
2988                next_default_member_name(&paths, &team_name).await?
2989            };
2990            let inserted = upsert_team_member(
2991                &paths,
2992                &team_name,
2993                &member_name,
2994                Some(input.subagent_type.clone()),
2995                input.model.clone(),
2996            )
2997            .await?;
2998            append_mailbox_message(
2999                &paths,
3000                &team_name,
3001                &member_name,
3002                json!({
3003                    "id": format!("msg_{}", uuid_like(now_ms_u64())),
3004                    "type": "task_prompt",
3005                    "from": args.get("sender").and_then(|v| v.as_str()).unwrap_or("team-lead"),
3006                    "to": member_name,
3007                    "content": input.prompt,
3008                    "summary": description,
3009                    "timestampMs": now_ms_u64(),
3010                    "read": false
3011                }),
3012            )
3013            .await?;
3014            let mut events = Vec::new();
3015            if inserted {
3016                events.push(json!({
3017                    "type": "agent_team.member.spawned",
3018                    "properties": {
3019                        "teamName": team_name,
3020                        "memberName": member_name,
3021                        "agentType": input.subagent_type,
3022                        "model": input.model,
3023                    }
3024                }));
3025            }
3026            events.push(json!({
3027                "type": "agent_team.mailbox.enqueued",
3028                "properties": {
3029                    "teamName": team_name,
3030                    "recipient": member_name,
3031                    "messageType": "task_prompt",
3032                }
3033            }));
3034            return Ok(ToolResult {
3035                output: format!("Teammate task queued for {}", member_name),
3036                metadata: json!({
3037                    "ok": true,
3038                    "team_name": team_name,
3039                    "teammate_name": member_name,
3040                    "events": events
3041                }),
3042            });
3043        }
3044        Ok(ToolResult {
3045            output: format!("Subtask planned: {description}"),
3046            metadata: json!({"description": description, "prompt": input.prompt}),
3047        })
3048    }
3049}
3050
3051struct QuestionTool;
3052#[async_trait]
3053impl Tool for QuestionTool {
3054    fn schema(&self) -> ToolSchema {
3055        tool_schema(
3056            "question",
3057            "Emit a question request for the user",
3058            json!({
3059                "type":"object",
3060                "properties":{
3061                    "questions":{
3062                        "type":"array",
3063                        "items":{
3064                            "type":"object",
3065                            "properties":{
3066                                "question":{"type":"string"},
3067                                "choices":{"type":"array","items":{"type":"string"}}
3068                            }
3069                        }
3070                    }
3071                }
3072            }),
3073        )
3074    }
3075    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
3076        let questions = normalize_question_payload(&args);
3077        if questions.is_empty() {
3078            return Err(anyhow!(
3079                "QUESTION_INVALID_ARGS: expected non-empty `questions` with at least one non-empty `question` string"
3080            ));
3081        }
3082        Ok(ToolResult {
3083            output: "Question requested. Use /question endpoints to respond.".to_string(),
3084            metadata: json!({"questions": questions}),
3085        })
3086    }
3087}
3088
3089fn normalize_question_payload(args: &Value) -> Vec<Value> {
3090    let parsed_args;
3091    let args = if let Some(raw) = args.as_str() {
3092        if let Ok(decoded) = serde_json::from_str::<Value>(raw) {
3093            parsed_args = decoded;
3094            &parsed_args
3095        } else {
3096            args
3097        }
3098    } else {
3099        args
3100    };
3101
3102    let Some(obj) = args.as_object() else {
3103        return Vec::new();
3104    };
3105
3106    if let Some(items) = obj.get("questions").and_then(|v| v.as_array()) {
3107        let normalized = items
3108            .iter()
3109            .filter_map(normalize_question_entry)
3110            .collect::<Vec<_>>();
3111        if !normalized.is_empty() {
3112            return normalized;
3113        }
3114    }
3115
3116    let question = obj
3117        .get("question")
3118        .or_else(|| obj.get("prompt"))
3119        .or_else(|| obj.get("query"))
3120        .or_else(|| obj.get("text"))
3121        .and_then(|v| v.as_str())
3122        .map(str::trim)
3123        .filter(|s| !s.is_empty());
3124    let Some(question) = question else {
3125        return Vec::new();
3126    };
3127    let options = obj
3128        .get("options")
3129        .or_else(|| obj.get("choices"))
3130        .and_then(|v| v.as_array())
3131        .map(|arr| {
3132            arr.iter()
3133                .filter_map(normalize_question_choice)
3134                .collect::<Vec<_>>()
3135        })
3136        .unwrap_or_default();
3137    let multiple = obj
3138        .get("multiple")
3139        .or_else(|| obj.get("multi_select"))
3140        .or_else(|| obj.get("multiSelect"))
3141        .and_then(|v| v.as_bool())
3142        .unwrap_or(false);
3143    let custom = obj
3144        .get("custom")
3145        .and_then(|v| v.as_bool())
3146        .unwrap_or(options.is_empty());
3147    vec![json!({
3148        "header": obj.get("header").and_then(|v| v.as_str()).unwrap_or("Question"),
3149        "question": question,
3150        "options": options,
3151        "multiple": multiple,
3152        "custom": custom
3153    })]
3154}
3155
3156fn normalize_question_entry(entry: &Value) -> Option<Value> {
3157    if let Some(raw) = entry.as_str() {
3158        let question = raw.trim();
3159        if question.is_empty() {
3160            return None;
3161        }
3162        return Some(json!({
3163            "header": "Question",
3164            "question": question,
3165            "options": [],
3166            "multiple": false,
3167            "custom": true
3168        }));
3169    }
3170    let obj = entry.as_object()?;
3171    let question = obj
3172        .get("question")
3173        .or_else(|| obj.get("prompt"))
3174        .or_else(|| obj.get("query"))
3175        .or_else(|| obj.get("text"))
3176        .and_then(|v| v.as_str())
3177        .map(str::trim)
3178        .filter(|s| !s.is_empty())?;
3179    let options = obj
3180        .get("options")
3181        .or_else(|| obj.get("choices"))
3182        .and_then(|v| v.as_array())
3183        .map(|arr| {
3184            arr.iter()
3185                .filter_map(normalize_question_choice)
3186                .collect::<Vec<_>>()
3187        })
3188        .unwrap_or_default();
3189    let multiple = obj
3190        .get("multiple")
3191        .or_else(|| obj.get("multi_select"))
3192        .or_else(|| obj.get("multiSelect"))
3193        .and_then(|v| v.as_bool())
3194        .unwrap_or(false);
3195    let custom = obj
3196        .get("custom")
3197        .and_then(|v| v.as_bool())
3198        .unwrap_or(options.is_empty());
3199    Some(json!({
3200        "header": obj.get("header").and_then(|v| v.as_str()).unwrap_or("Question"),
3201        "question": question,
3202        "options": options,
3203        "multiple": multiple,
3204        "custom": custom
3205    }))
3206}
3207
3208fn normalize_question_choice(choice: &Value) -> Option<Value> {
3209    if let Some(label) = choice.as_str().map(str::trim).filter(|s| !s.is_empty()) {
3210        return Some(json!({
3211            "label": label,
3212            "description": ""
3213        }));
3214    }
3215    let obj = choice.as_object()?;
3216    let label = obj
3217        .get("label")
3218        .or_else(|| obj.get("title"))
3219        .or_else(|| obj.get("name"))
3220        .or_else(|| obj.get("value"))
3221        .or_else(|| obj.get("text"))
3222        .and_then(|v| {
3223            if let Some(s) = v.as_str() {
3224                Some(s.trim().to_string())
3225            } else {
3226                v.as_i64()
3227                    .map(|n| n.to_string())
3228                    .or_else(|| v.as_u64().map(|n| n.to_string()))
3229            }
3230        })
3231        .filter(|s| !s.is_empty())?;
3232    let description = obj
3233        .get("description")
3234        .or_else(|| obj.get("hint"))
3235        .or_else(|| obj.get("subtitle"))
3236        .and_then(|v| v.as_str())
3237        .unwrap_or("")
3238        .to_string();
3239    Some(json!({
3240        "label": label,
3241        "description": description
3242    }))
3243}
3244
3245struct SpawnAgentTool;
3246#[async_trait]
3247impl Tool for SpawnAgentTool {
3248    fn schema(&self) -> ToolSchema {
3249        tool_schema(
3250            "spawn_agent",
3251            "Spawn an agent-team instance through server policy enforcement.",
3252            json!({
3253                "type":"object",
3254                "properties":{
3255                    "missionID":{"type":"string"},
3256                    "parentInstanceID":{"type":"string"},
3257                    "templateID":{"type":"string"},
3258                    "role":{"type":"string","enum":["orchestrator","delegator","worker","watcher","reviewer","tester","committer"]},
3259                    "source":{"type":"string","enum":["tool_call"]},
3260                    "justification":{"type":"string"},
3261                    "budgetOverride":{"type":"object"}
3262                },
3263                "required":["role","justification"]
3264            }),
3265        )
3266    }
3267
3268    async fn execute(&self, _args: Value) -> anyhow::Result<ToolResult> {
3269        Ok(ToolResult {
3270            output: "spawn_agent must be executed through the engine runtime.".to_string(),
3271            metadata: json!({
3272                "ok": false,
3273                "code": "SPAWN_HOOK_UNAVAILABLE"
3274            }),
3275        })
3276    }
3277}
3278
3279struct TeamCreateTool;
3280#[async_trait]
3281impl Tool for TeamCreateTool {
3282    fn schema(&self) -> ToolSchema {
3283        tool_schema(
3284            "TeamCreate",
3285            "Create a coordinated team and shared task context.",
3286            team_create_schema(),
3287        )
3288    }
3289
3290    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
3291        let input = serde_json::from_value::<TeamCreateInput>(args.clone())
3292            .map_err(|err| anyhow!("invalid TeamCreate args: {}", err))?;
3293        let now_ms = now_ms_u64();
3294        let paths = resolve_agent_team_paths(&args)?;
3295        let team_name = sanitize_team_name(&input.team_name)?;
3296        let team_dir = paths.team_dir(&team_name);
3297        fs::create_dir_all(paths.tasks_dir(&team_name)).await?;
3298        fs::create_dir_all(paths.mailboxes_dir(&team_name)).await?;
3299        fs::create_dir_all(paths.requests_dir(&team_name)).await?;
3300
3301        let config = json!({
3302            "teamName": team_name,
3303            "description": input.description,
3304            "agentType": input.agent_type,
3305            "createdAtMs": now_ms
3306        });
3307        write_json_file(paths.config_file(&team_name), &config).await?;
3308
3309        let lead_name = args
3310            .get("lead_name")
3311            .and_then(|v| v.as_str())
3312            .filter(|s| !s.trim().is_empty())
3313            .unwrap_or("A1");
3314        let members = json!([{
3315            "name": lead_name,
3316            "agentType": input.agent_type.clone().unwrap_or_else(|| "lead".to_string()),
3317            "createdAtMs": now_ms
3318        }]);
3319        write_json_file(paths.members_file(&team_name), &members).await?;
3320
3321        upsert_team_index(&paths, &team_name).await?;
3322        if let Some(session_id) = args.get("__session_id").and_then(|v| v.as_str()) {
3323            write_team_session_context(&paths, session_id, &team_name).await?;
3324        }
3325
3326        Ok(ToolResult {
3327            output: format!("Team created: {}", team_name),
3328            metadata: json!({
3329                "ok": true,
3330                "team_name": team_name,
3331                "path": team_dir.to_string_lossy(),
3332                "events": [{
3333                    "type": "agent_team.team.created",
3334                    "properties": {
3335                        "teamName": team_name,
3336                        "path": team_dir.to_string_lossy(),
3337                    }
3338                }]
3339            }),
3340        })
3341    }
3342}
3343
3344struct TaskCreateCompatTool;
3345#[async_trait]
3346impl Tool for TaskCreateCompatTool {
3347    fn schema(&self) -> ToolSchema {
3348        tool_schema(
3349            "TaskCreate",
3350            "Create a task in the shared team task list.",
3351            task_create_schema(),
3352        )
3353    }
3354
3355    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
3356        let input = serde_json::from_value::<TaskCreateInput>(args.clone())
3357            .map_err(|err| anyhow!("invalid TaskCreate args: {}", err))?;
3358        let paths = resolve_agent_team_paths(&args)?;
3359        let team_name = resolve_team_name(&paths, &args).await?;
3360        let tasks_dir = paths.tasks_dir(&team_name);
3361        fs::create_dir_all(&tasks_dir).await?;
3362        let next_id = next_task_id(&tasks_dir).await?;
3363        let now_ms = now_ms_u64();
3364        let task = json!({
3365            "id": next_id.to_string(),
3366            "subject": input.subject,
3367            "description": input.description,
3368            "activeForm": input.active_form,
3369            "status": "pending",
3370            "owner": Value::Null,
3371            "blocks": [],
3372            "blockedBy": [],
3373            "metadata": input.metadata.unwrap_or_else(|| json!({})),
3374            "createdAtMs": now_ms,
3375            "updatedAtMs": now_ms
3376        });
3377        write_json_file(paths.task_file(&team_name, &next_id.to_string()), &task).await?;
3378        Ok(ToolResult {
3379            output: format!("Task created: {}", next_id),
3380            metadata: json!({
3381                "ok": true,
3382                "team_name": team_name,
3383                "task": task,
3384                "events": [{
3385                    "type": "agent_team.task.created",
3386                    "properties": {
3387                        "teamName": team_name,
3388                        "taskId": next_id.to_string(),
3389                    }
3390                }]
3391            }),
3392        })
3393    }
3394}
3395
3396struct TaskUpdateCompatTool;
3397#[async_trait]
3398impl Tool for TaskUpdateCompatTool {
3399    fn schema(&self) -> ToolSchema {
3400        tool_schema(
3401            "TaskUpdate",
3402            "Update ownership/state/dependencies of a shared task.",
3403            task_update_schema(),
3404        )
3405    }
3406
3407    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
3408        let input = serde_json::from_value::<TaskUpdateInput>(args.clone())
3409            .map_err(|err| anyhow!("invalid TaskUpdate args: {}", err))?;
3410        let paths = resolve_agent_team_paths(&args)?;
3411        let team_name = resolve_team_name(&paths, &args).await?;
3412        let task_path = paths.task_file(&team_name, &input.task_id);
3413        if !task_path.exists() {
3414            return Ok(ToolResult {
3415                output: format!("Task not found: {}", input.task_id),
3416                metadata: json!({"ok": false, "code": "TASK_NOT_FOUND"}),
3417            });
3418        }
3419        let raw = fs::read_to_string(&task_path).await?;
3420        let mut task = serde_json::from_str::<Value>(&raw)
3421            .map_err(|err| anyhow!("failed parsing task {}: {}", input.task_id, err))?;
3422        let Some(obj) = task.as_object_mut() else {
3423            return Err(anyhow!("task payload is not an object"));
3424        };
3425
3426        if let Some(subject) = input.subject {
3427            obj.insert("subject".to_string(), Value::String(subject));
3428        }
3429        if let Some(description) = input.description {
3430            obj.insert("description".to_string(), Value::String(description));
3431        }
3432        if let Some(active) = input.active_form {
3433            obj.insert("activeForm".to_string(), Value::String(active));
3434        }
3435        if let Some(status) = input.status {
3436            if status == "deleted" {
3437                let _ = fs::remove_file(&task_path).await;
3438                return Ok(ToolResult {
3439                    output: format!("Task deleted: {}", input.task_id),
3440                    metadata: json!({
3441                        "ok": true,
3442                        "deleted": true,
3443                        "taskId": input.task_id,
3444                        "events": [{
3445                            "type": "agent_team.task.deleted",
3446                            "properties": {
3447                                "teamName": team_name,
3448                                "taskId": input.task_id
3449                            }
3450                        }]
3451                    }),
3452                });
3453            }
3454            obj.insert("status".to_string(), Value::String(status));
3455        }
3456        if let Some(owner) = input.owner {
3457            obj.insert("owner".to_string(), Value::String(owner));
3458        }
3459        if let Some(add_blocks) = input.add_blocks {
3460            let current = obj
3461                .get("blocks")
3462                .and_then(|v| v.as_array())
3463                .cloned()
3464                .unwrap_or_default();
3465            obj.insert(
3466                "blocks".to_string(),
3467                Value::Array(merge_unique_strings(current, add_blocks)),
3468            );
3469        }
3470        if let Some(add_blocked_by) = input.add_blocked_by {
3471            let current = obj
3472                .get("blockedBy")
3473                .and_then(|v| v.as_array())
3474                .cloned()
3475                .unwrap_or_default();
3476            obj.insert(
3477                "blockedBy".to_string(),
3478                Value::Array(merge_unique_strings(current, add_blocked_by)),
3479            );
3480        }
3481        if let Some(metadata) = input.metadata {
3482            if let Some(current) = obj.get_mut("metadata").and_then(|v| v.as_object_mut()) {
3483                if let Some(incoming) = metadata.as_object() {
3484                    for (k, v) in incoming {
3485                        if v.is_null() {
3486                            current.remove(k);
3487                        } else {
3488                            current.insert(k.clone(), v.clone());
3489                        }
3490                    }
3491                }
3492            } else {
3493                obj.insert("metadata".to_string(), metadata);
3494            }
3495        }
3496        obj.insert("updatedAtMs".to_string(), json!(now_ms_u64()));
3497        write_json_file(task_path, &task).await?;
3498        Ok(ToolResult {
3499            output: format!("Task updated: {}", input.task_id),
3500            metadata: json!({
3501                "ok": true,
3502                "team_name": team_name,
3503                "task": task,
3504                "events": [{
3505                    "type": "agent_team.task.updated",
3506                    "properties": {
3507                        "teamName": team_name,
3508                        "taskId": input.task_id
3509                    }
3510                }]
3511            }),
3512        })
3513    }
3514}
3515
3516struct TaskListCompatTool;
3517#[async_trait]
3518impl Tool for TaskListCompatTool {
3519    fn schema(&self) -> ToolSchema {
3520        tool_schema(
3521            "TaskList",
3522            "List tasks from the shared task list.",
3523            task_list_schema(),
3524        )
3525    }
3526
3527    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
3528        let _ = serde_json::from_value::<TaskListInput>(args.clone())
3529            .map_err(|err| anyhow!("invalid TaskList args: {}", err))?;
3530        let paths = resolve_agent_team_paths(&args)?;
3531        let team_name = resolve_team_name(&paths, &args).await?;
3532        let tasks = read_tasks(&paths.tasks_dir(&team_name)).await?;
3533        let mut lines = Vec::new();
3534        for task in &tasks {
3535            let id = task
3536                .get("id")
3537                .and_then(|v| v.as_str())
3538                .unwrap_or("?")
3539                .to_string();
3540            let subject = task
3541                .get("subject")
3542                .and_then(|v| v.as_str())
3543                .unwrap_or("(untitled)")
3544                .to_string();
3545            let status = task
3546                .get("status")
3547                .and_then(|v| v.as_str())
3548                .unwrap_or("pending")
3549                .to_string();
3550            let owner = task
3551                .get("owner")
3552                .and_then(|v| v.as_str())
3553                .unwrap_or("")
3554                .to_string();
3555            lines.push(format!(
3556                "{} [{}] {}{}",
3557                id,
3558                status,
3559                subject,
3560                if owner.is_empty() {
3561                    "".to_string()
3562                } else {
3563                    format!(" (owner: {})", owner)
3564                }
3565            ));
3566        }
3567        Ok(ToolResult {
3568            output: if lines.is_empty() {
3569                "No tasks.".to_string()
3570            } else {
3571                lines.join("\n")
3572            },
3573            metadata: json!({
3574                "ok": true,
3575                "team_name": team_name,
3576                "tasks": tasks
3577            }),
3578        })
3579    }
3580}
3581
3582struct SendMessageCompatTool;
3583#[async_trait]
3584impl Tool for SendMessageCompatTool {
3585    fn schema(&self) -> ToolSchema {
3586        tool_schema(
3587            "SendMessage",
3588            "Send teammate messages and coordination protocol responses.",
3589            send_message_schema(),
3590        )
3591    }
3592
3593    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
3594        let input = serde_json::from_value::<SendMessageInput>(args.clone())
3595            .map_err(|err| anyhow!("invalid SendMessage args: {}", err))?;
3596        let paths = resolve_agent_team_paths(&args)?;
3597        let team_name = resolve_team_name(&paths, &args).await?;
3598        fs::create_dir_all(paths.mailboxes_dir(&team_name)).await?;
3599        let sender = args
3600            .get("sender")
3601            .and_then(|v| v.as_str())
3602            .filter(|s| !s.trim().is_empty())
3603            .unwrap_or("team-lead")
3604            .to_string();
3605        let now_ms = now_ms_u64();
3606
3607        match input.message_type {
3608            SendMessageType::Message | SendMessageType::ShutdownRequest => {
3609                let recipient = required_non_empty(input.recipient, "recipient")?;
3610                let content = required_non_empty(input.content, "content")?;
3611                append_mailbox_message(
3612                    &paths,
3613                    &team_name,
3614                    &recipient,
3615                    json!({
3616                        "id": format!("msg_{}", uuid_like(now_ms)),
3617                        "type": message_type_name(&input.message_type),
3618                        "from": sender,
3619                        "to": recipient,
3620                        "content": content,
3621                        "summary": input.summary,
3622                        "timestampMs": now_ms,
3623                        "read": false
3624                    }),
3625                )
3626                .await?;
3627                Ok(ToolResult {
3628                    output: "Message queued.".to_string(),
3629                    metadata: json!({
3630                        "ok": true,
3631                        "team_name": team_name,
3632                        "events": [{
3633                            "type": "agent_team.mailbox.enqueued",
3634                            "properties": {
3635                                "teamName": team_name,
3636                                "recipient": recipient,
3637                                "messageType": message_type_name(&input.message_type),
3638                            }
3639                        }]
3640                    }),
3641                })
3642            }
3643            SendMessageType::Broadcast => {
3644                let content = required_non_empty(input.content, "content")?;
3645                let members = read_team_member_names(&paths, &team_name).await?;
3646                for recipient in members {
3647                    append_mailbox_message(
3648                        &paths,
3649                        &team_name,
3650                        &recipient,
3651                        json!({
3652                            "id": format!("msg_{}_{}", uuid_like(now_ms), recipient),
3653                            "type": "broadcast",
3654                            "from": sender,
3655                            "to": recipient,
3656                            "content": content,
3657                            "summary": input.summary,
3658                            "timestampMs": now_ms,
3659                            "read": false
3660                        }),
3661                    )
3662                    .await?;
3663                }
3664                Ok(ToolResult {
3665                    output: "Broadcast queued.".to_string(),
3666                    metadata: json!({
3667                        "ok": true,
3668                        "team_name": team_name,
3669                        "events": [{
3670                            "type": "agent_team.mailbox.enqueued",
3671                            "properties": {
3672                                "teamName": team_name,
3673                                "recipient": "*",
3674                                "messageType": "broadcast",
3675                            }
3676                        }]
3677                    }),
3678                })
3679            }
3680            SendMessageType::ShutdownResponse | SendMessageType::PlanApprovalResponse => {
3681                let request_id = required_non_empty(input.request_id, "request_id")?;
3682                let request = json!({
3683                    "requestId": request_id,
3684                    "type": message_type_name(&input.message_type),
3685                    "from": sender,
3686                    "recipient": input.recipient,
3687                    "approve": input.approve,
3688                    "content": input.content,
3689                    "updatedAtMs": now_ms
3690                });
3691                write_json_file(paths.request_file(&team_name, &request_id), &request).await?;
3692                Ok(ToolResult {
3693                    output: "Response recorded.".to_string(),
3694                    metadata: json!({
3695                        "ok": true,
3696                        "team_name": team_name,
3697                        "request": request,
3698                        "events": [{
3699                            "type": "agent_team.request.resolved",
3700                            "properties": {
3701                                "teamName": team_name,
3702                                "requestId": request_id,
3703                                "requestType": message_type_name(&input.message_type),
3704                                "approve": input.approve
3705                            }
3706                        }]
3707                    }),
3708                })
3709            }
3710        }
3711    }
3712}
3713
3714fn message_type_name(ty: &SendMessageType) -> &'static str {
3715    match ty {
3716        SendMessageType::Message => "message",
3717        SendMessageType::Broadcast => "broadcast",
3718        SendMessageType::ShutdownRequest => "shutdown_request",
3719        SendMessageType::ShutdownResponse => "shutdown_response",
3720        SendMessageType::PlanApprovalResponse => "plan_approval_response",
3721    }
3722}
3723
3724fn required_non_empty(value: Option<String>, field: &str) -> anyhow::Result<String> {
3725    let Some(v) = value
3726        .map(|s| s.trim().to_string())
3727        .filter(|s| !s.is_empty())
3728    else {
3729        return Err(anyhow!("{} is required", field));
3730    };
3731    Ok(v)
3732}
3733
3734fn resolve_agent_team_paths(args: &Value) -> anyhow::Result<AgentTeamPaths> {
3735    let workspace_root = args
3736        .get("__workspace_root")
3737        .and_then(|v| v.as_str())
3738        .map(PathBuf::from)
3739        .or_else(|| std::env::current_dir().ok())
3740        .ok_or_else(|| anyhow!("workspace root unavailable"))?;
3741    Ok(AgentTeamPaths::new(workspace_root.join(".tandem")))
3742}
3743
3744async fn resolve_team_name(paths: &AgentTeamPaths, args: &Value) -> anyhow::Result<String> {
3745    if let Some(name) = args
3746        .get("team_name")
3747        .and_then(|v| v.as_str())
3748        .map(str::trim)
3749        .filter(|s| !s.is_empty())
3750    {
3751        return sanitize_team_name(name);
3752    }
3753    if let Some(session_id) = args.get("__session_id").and_then(|v| v.as_str()) {
3754        let context_path = paths
3755            .root()
3756            .join("session-context")
3757            .join(format!("{}.json", session_id));
3758        if context_path.exists() {
3759            let raw = fs::read_to_string(context_path).await?;
3760            let parsed = serde_json::from_str::<Value>(&raw)?;
3761            if let Some(name) = parsed
3762                .get("team_name")
3763                .and_then(|v| v.as_str())
3764                .map(str::trim)
3765                .filter(|s| !s.is_empty())
3766            {
3767                return sanitize_team_name(name);
3768            }
3769        }
3770    }
3771    Err(anyhow!(
3772        "team_name is required (no active team context for this session)"
3773    ))
3774}
3775
3776fn sanitize_team_name(input: &str) -> anyhow::Result<String> {
3777    let trimmed = input.trim();
3778    if trimmed.is_empty() {
3779        return Err(anyhow!("team_name cannot be empty"));
3780    }
3781    let sanitized = trimmed
3782        .chars()
3783        .map(|c| {
3784            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
3785                c
3786            } else {
3787                '-'
3788            }
3789        })
3790        .collect::<String>();
3791    Ok(sanitized)
3792}
3793
3794fn sanitize_member_name(input: &str) -> anyhow::Result<String> {
3795    let trimmed = input.trim();
3796    if trimmed.is_empty() {
3797        return Err(anyhow!("member name cannot be empty"));
3798    }
3799    if let Some(rest) = trimmed
3800        .strip_prefix('A')
3801        .or_else(|| trimmed.strip_prefix('a'))
3802    {
3803        if let Ok(n) = rest.parse::<u32>() {
3804            if n > 0 {
3805                return Ok(format!("A{}", n));
3806            }
3807        }
3808    }
3809    let sanitized = trimmed
3810        .chars()
3811        .map(|c| {
3812            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
3813                c
3814            } else {
3815                '-'
3816            }
3817        })
3818        .collect::<String>();
3819    if sanitized.is_empty() {
3820        return Err(anyhow!("member name cannot be empty"));
3821    }
3822    Ok(sanitized)
3823}
3824
3825async fn next_default_member_name(
3826    paths: &AgentTeamPaths,
3827    team_name: &str,
3828) -> anyhow::Result<String> {
3829    let names = read_team_member_names(paths, team_name).await?;
3830    let mut max_index = 1u32;
3831    for name in names {
3832        let trimmed = name.trim();
3833        let Some(rest) = trimmed
3834            .strip_prefix('A')
3835            .or_else(|| trimmed.strip_prefix('a'))
3836        else {
3837            continue;
3838        };
3839        let Ok(index) = rest.parse::<u32>() else {
3840            continue;
3841        };
3842        if index > max_index {
3843            max_index = index;
3844        }
3845    }
3846    Ok(format!("A{}", max_index.saturating_add(1)))
3847}
3848
3849async fn write_json_file(path: PathBuf, value: &Value) -> anyhow::Result<()> {
3850    if let Some(parent) = path.parent() {
3851        fs::create_dir_all(parent).await?;
3852    }
3853    fs::write(path, serde_json::to_vec_pretty(value)?).await?;
3854    Ok(())
3855}
3856
3857async fn upsert_team_index(paths: &AgentTeamPaths, team_name: &str) -> anyhow::Result<()> {
3858    let index_path = paths.index_file();
3859    let mut teams = if index_path.exists() {
3860        let raw = fs::read_to_string(&index_path).await?;
3861        serde_json::from_str::<Vec<String>>(&raw).unwrap_or_default()
3862    } else {
3863        Vec::new()
3864    };
3865    if !teams.iter().any(|team| team == team_name) {
3866        teams.push(team_name.to_string());
3867        teams.sort();
3868    }
3869    write_json_file(index_path, &json!(teams)).await
3870}
3871
3872async fn write_team_session_context(
3873    paths: &AgentTeamPaths,
3874    session_id: &str,
3875    team_name: &str,
3876) -> anyhow::Result<()> {
3877    let context_path = paths
3878        .root()
3879        .join("session-context")
3880        .join(format!("{}.json", session_id));
3881    write_json_file(context_path, &json!({ "team_name": team_name })).await
3882}
3883
3884async fn next_task_id(tasks_dir: &Path) -> anyhow::Result<u64> {
3885    let mut max_id = 0u64;
3886    let mut entries = match fs::read_dir(tasks_dir).await {
3887        Ok(entries) => entries,
3888        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(1),
3889        Err(err) => return Err(err.into()),
3890    };
3891    while let Some(entry) = entries.next_entry().await? {
3892        let path = entry.path();
3893        if !path.is_file() {
3894            continue;
3895        }
3896        let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
3897            continue;
3898        };
3899        if let Ok(id) = stem.parse::<u64>() {
3900            max_id = max_id.max(id);
3901        }
3902    }
3903    Ok(max_id + 1)
3904}
3905
3906fn merge_unique_strings(current: Vec<Value>, incoming: Vec<String>) -> Vec<Value> {
3907    let mut seen = HashSet::new();
3908    let mut out = Vec::new();
3909    for value in current {
3910        if let Some(text) = value.as_str() {
3911            let text = text.to_string();
3912            if seen.insert(text.clone()) {
3913                out.push(Value::String(text));
3914            }
3915        }
3916    }
3917    for value in incoming {
3918        if seen.insert(value.clone()) {
3919            out.push(Value::String(value));
3920        }
3921    }
3922    out
3923}
3924
3925async fn read_tasks(tasks_dir: &Path) -> anyhow::Result<Vec<Value>> {
3926    let mut tasks = Vec::new();
3927    let mut entries = match fs::read_dir(tasks_dir).await {
3928        Ok(entries) => entries,
3929        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(tasks),
3930        Err(err) => return Err(err.into()),
3931    };
3932    while let Some(entry) = entries.next_entry().await? {
3933        let path = entry.path();
3934        if !path.is_file() {
3935            continue;
3936        }
3937        let raw = fs::read_to_string(path).await?;
3938        let task = serde_json::from_str::<Value>(&raw)?;
3939        tasks.push(task);
3940    }
3941    tasks.sort_by_key(|task| {
3942        task.get("id")
3943            .and_then(|v| v.as_str())
3944            .and_then(|s| s.parse::<u64>().ok())
3945            .unwrap_or(0)
3946    });
3947    Ok(tasks)
3948}
3949
3950async fn append_mailbox_message(
3951    paths: &AgentTeamPaths,
3952    team_name: &str,
3953    recipient: &str,
3954    message: Value,
3955) -> anyhow::Result<()> {
3956    let mailbox_path = paths.mailbox_file(team_name, recipient);
3957    if let Some(parent) = mailbox_path.parent() {
3958        fs::create_dir_all(parent).await?;
3959    }
3960    let line = format!("{}\n", serde_json::to_string(&message)?);
3961    if mailbox_path.exists() {
3962        use tokio::io::AsyncWriteExt;
3963        let mut file = tokio::fs::OpenOptions::new()
3964            .create(true)
3965            .append(true)
3966            .open(mailbox_path)
3967            .await?;
3968        file.write_all(line.as_bytes()).await?;
3969        file.flush().await?;
3970    } else {
3971        fs::write(mailbox_path, line).await?;
3972    }
3973    Ok(())
3974}
3975
3976async fn read_team_member_names(
3977    paths: &AgentTeamPaths,
3978    team_name: &str,
3979) -> anyhow::Result<Vec<String>> {
3980    let members_path = paths.members_file(team_name);
3981    if !members_path.exists() {
3982        return Ok(Vec::new());
3983    }
3984    let raw = fs::read_to_string(members_path).await?;
3985    let parsed = serde_json::from_str::<Value>(&raw)?;
3986    let Some(items) = parsed.as_array() else {
3987        return Ok(Vec::new());
3988    };
3989    let mut out = Vec::new();
3990    for item in items {
3991        if let Some(name) = item
3992            .get("name")
3993            .and_then(|v| v.as_str())
3994            .map(str::trim)
3995            .filter(|s| !s.is_empty())
3996        {
3997            out.push(name.to_string());
3998        }
3999    }
4000    Ok(out)
4001}
4002
4003async fn upsert_team_member(
4004    paths: &AgentTeamPaths,
4005    team_name: &str,
4006    member_name: &str,
4007    agent_type: Option<String>,
4008    model: Option<String>,
4009) -> anyhow::Result<bool> {
4010    let members_path = paths.members_file(team_name);
4011    let mut members = if members_path.exists() {
4012        let raw = fs::read_to_string(&members_path).await?;
4013        serde_json::from_str::<Value>(&raw)?
4014            .as_array()
4015            .cloned()
4016            .unwrap_or_default()
4017    } else {
4018        Vec::new()
4019    };
4020    let already_present = members.iter().any(|item| {
4021        item.get("name")
4022            .and_then(|v| v.as_str())
4023            .map(|s| s == member_name)
4024            .unwrap_or(false)
4025    });
4026    if already_present {
4027        return Ok(false);
4028    }
4029    members.push(json!({
4030        "name": member_name,
4031        "agentType": agent_type,
4032        "model": model,
4033        "createdAtMs": now_ms_u64()
4034    }));
4035    write_json_file(members_path, &Value::Array(members)).await?;
4036    Ok(true)
4037}
4038
4039fn now_ms_u64() -> u64 {
4040    std::time::SystemTime::now()
4041        .duration_since(std::time::UNIX_EPOCH)
4042        .map(|d| d.as_millis() as u64)
4043        .unwrap_or(0)
4044}
4045
4046fn uuid_like(seed: u64) -> String {
4047    format!("{:x}", seed)
4048}
4049
4050struct MemorySearchTool;
4051#[async_trait]
4052impl Tool for MemorySearchTool {
4053    fn schema(&self) -> ToolSchema {
4054        tool_schema(
4055            "memory_search",
4056            "Search tandem memory across session/project/global tiers. If scope fields are omitted, the tool defaults to the current session/project context and may include global memory when policy allows it.",
4057            json!({
4058                "type":"object",
4059                "properties":{
4060                    "query":{"type":"string"},
4061                    "session_id":{"type":"string"},
4062                    "project_id":{"type":"string"},
4063                    "tier":{"type":"string","enum":["session","project","global"]},
4064                    "limit":{"type":"integer","minimum":1,"maximum":20},
4065                    "allow_global":{"type":"boolean"}
4066                },
4067                "required":["query"]
4068            }),
4069        )
4070    }
4071
4072    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
4073        let query = args
4074            .get("query")
4075            .or_else(|| args.get("q"))
4076            .and_then(|v| v.as_str())
4077            .map(str::trim)
4078            .unwrap_or("");
4079        if query.is_empty() {
4080            return Ok(ToolResult {
4081                output: "memory_search requires a non-empty query".to_string(),
4082                metadata: json!({"ok": false, "reason": "missing_query"}),
4083            });
4084        }
4085
4086        let session_id = memory_session_id(&args);
4087        let project_id = memory_project_id(&args);
4088        let allow_global = global_memory_enabled(&args);
4089        if session_id.is_none() && project_id.is_none() && !allow_global {
4090            return Ok(ToolResult {
4091                output: "memory_search requires a current session/project context or global memory enabled by policy"
4092                    .to_string(),
4093                metadata: json!({"ok": false, "reason": "missing_scope"}),
4094            });
4095        }
4096
4097        let tier = match args
4098            .get("tier")
4099            .and_then(|v| v.as_str())
4100            .map(|s| s.trim().to_ascii_lowercase())
4101        {
4102            Some(t) if t == "session" => Some(MemoryTier::Session),
4103            Some(t) if t == "project" => Some(MemoryTier::Project),
4104            Some(t) if t == "global" => Some(MemoryTier::Global),
4105            Some(_) => {
4106                return Ok(ToolResult {
4107                    output: "memory_search tier must be one of: session, project, global"
4108                        .to_string(),
4109                    metadata: json!({"ok": false, "reason": "invalid_tier"}),
4110                });
4111            }
4112            None => None,
4113        };
4114        if matches!(tier, Some(MemoryTier::Session)) && session_id.is_none() {
4115            return Ok(ToolResult {
4116                output: "tier=session requires session_id".to_string(),
4117                metadata: json!({"ok": false, "reason": "missing_session_scope"}),
4118            });
4119        }
4120        if matches!(tier, Some(MemoryTier::Project)) && project_id.is_none() {
4121            return Ok(ToolResult {
4122                output: "tier=project requires project_id".to_string(),
4123                metadata: json!({"ok": false, "reason": "missing_project_scope"}),
4124            });
4125        }
4126        if matches!(tier, Some(MemoryTier::Global)) && !allow_global {
4127            return Ok(ToolResult {
4128                output: "tier=global requires allow_global=true".to_string(),
4129                metadata: json!({"ok": false, "reason": "global_scope_disabled"}),
4130            });
4131        }
4132
4133        let limit = args
4134            .get("limit")
4135            .and_then(|v| v.as_i64())
4136            .unwrap_or(5)
4137            .clamp(1, 20);
4138
4139        let db_path = resolve_memory_db_path(&args);
4140        let db_exists = db_path.exists();
4141        if !db_exists {
4142            return Ok(ToolResult {
4143                output: "memory database not found".to_string(),
4144                metadata: json!({
4145                    "ok": false,
4146                    "reason": "memory_db_missing",
4147                    "db_path": db_path,
4148                }),
4149            });
4150        }
4151
4152        let manager = MemoryManager::new(&db_path).await?;
4153        let health = manager.embedding_health().await;
4154        if health.status != "ok" {
4155            return Ok(ToolResult {
4156                output: "memory embeddings unavailable; semantic search is disabled".to_string(),
4157                metadata: json!({
4158                    "ok": false,
4159                    "reason": "embeddings_unavailable",
4160                    "embedding_status": health.status,
4161                    "embedding_reason": health.reason,
4162                }),
4163            });
4164        }
4165
4166        let mut results: Vec<MemorySearchResult> = Vec::new();
4167        match tier {
4168            Some(MemoryTier::Session) => {
4169                results.extend(
4170                    manager
4171                        .search(
4172                            query,
4173                            Some(MemoryTier::Session),
4174                            project_id.as_deref(),
4175                            session_id.as_deref(),
4176                            Some(limit),
4177                        )
4178                        .await?,
4179                );
4180            }
4181            Some(MemoryTier::Project) => {
4182                results.extend(
4183                    manager
4184                        .search(
4185                            query,
4186                            Some(MemoryTier::Project),
4187                            project_id.as_deref(),
4188                            session_id.as_deref(),
4189                            Some(limit),
4190                        )
4191                        .await?,
4192                );
4193            }
4194            Some(MemoryTier::Global) => {
4195                results.extend(
4196                    manager
4197                        .search(query, Some(MemoryTier::Global), None, None, Some(limit))
4198                        .await?,
4199                );
4200            }
4201            _ => {
4202                if session_id.is_some() {
4203                    results.extend(
4204                        manager
4205                            .search(
4206                                query,
4207                                Some(MemoryTier::Session),
4208                                project_id.as_deref(),
4209                                session_id.as_deref(),
4210                                Some(limit),
4211                            )
4212                            .await?,
4213                    );
4214                }
4215                if project_id.is_some() {
4216                    results.extend(
4217                        manager
4218                            .search(
4219                                query,
4220                                Some(MemoryTier::Project),
4221                                project_id.as_deref(),
4222                                session_id.as_deref(),
4223                                Some(limit),
4224                            )
4225                            .await?,
4226                    );
4227                }
4228                if allow_global {
4229                    results.extend(
4230                        manager
4231                            .search(query, Some(MemoryTier::Global), None, None, Some(limit))
4232                            .await?,
4233                    );
4234                }
4235            }
4236        }
4237
4238        let mut dedup: HashMap<String, MemorySearchResult> = HashMap::new();
4239        for result in results {
4240            match dedup.get(&result.chunk.id) {
4241                Some(existing) if existing.similarity >= result.similarity => {}
4242                _ => {
4243                    dedup.insert(result.chunk.id.clone(), result);
4244                }
4245            }
4246        }
4247        let mut merged = dedup.into_values().collect::<Vec<_>>();
4248        merged.sort_by(|a, b| b.similarity.total_cmp(&a.similarity));
4249        merged.truncate(limit as usize);
4250
4251        let output_rows = merged
4252            .iter()
4253            .map(|item| {
4254                json!({
4255                    "chunk_id": item.chunk.id,
4256                    "tier": item.chunk.tier.to_string(),
4257                    "session_id": item.chunk.session_id,
4258                    "project_id": item.chunk.project_id,
4259                    "source": item.chunk.source,
4260                    "similarity": item.similarity,
4261                    "content": item.chunk.content,
4262                    "created_at": item.chunk.created_at,
4263                })
4264            })
4265            .collect::<Vec<_>>();
4266
4267        Ok(ToolResult {
4268            output: serde_json::to_string_pretty(&output_rows).unwrap_or_default(),
4269            metadata: json!({
4270                "ok": true,
4271                "count": output_rows.len(),
4272                "limit": limit,
4273                "query": query,
4274                "session_id": session_id,
4275                "project_id": project_id,
4276                "allow_global": allow_global,
4277                "embedding_status": health.status,
4278                "embedding_reason": health.reason,
4279                "strict_scope": !allow_global,
4280            }),
4281        })
4282    }
4283}
4284
4285struct MemoryStoreTool;
4286#[async_trait]
4287impl Tool for MemoryStoreTool {
4288    fn schema(&self) -> ToolSchema {
4289        tool_schema(
4290            "memory_store",
4291            "Store memory chunks in session/project/global tiers. If scope is omitted, the tool defaults to the current project, then session, and only uses global memory when policy allows it.",
4292            json!({
4293                "type":"object",
4294                "properties":{
4295                    "content":{"type":"string"},
4296                    "tier":{"type":"string","enum":["session","project","global"]},
4297                    "session_id":{"type":"string"},
4298                    "project_id":{"type":"string"},
4299                    "source":{"type":"string"},
4300                    "metadata":{"type":"object"},
4301                    "allow_global":{"type":"boolean"}
4302                },
4303                "required":["content"]
4304            }),
4305        )
4306    }
4307
4308    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
4309        let content = args
4310            .get("content")
4311            .and_then(|v| v.as_str())
4312            .map(str::trim)
4313            .unwrap_or("");
4314        if content.is_empty() {
4315            return Ok(ToolResult {
4316                output: "memory_store requires non-empty content".to_string(),
4317                metadata: json!({"ok": false, "reason": "missing_content"}),
4318            });
4319        }
4320
4321        let session_id = memory_session_id(&args);
4322        let project_id = memory_project_id(&args);
4323        let allow_global = global_memory_enabled(&args);
4324
4325        let tier = match args
4326            .get("tier")
4327            .and_then(|v| v.as_str())
4328            .map(|s| s.trim().to_ascii_lowercase())
4329        {
4330            Some(t) if t == "session" => MemoryTier::Session,
4331            Some(t) if t == "project" => MemoryTier::Project,
4332            Some(t) if t == "global" => MemoryTier::Global,
4333            Some(_) => {
4334                return Ok(ToolResult {
4335                    output: "memory_store tier must be one of: session, project, global"
4336                        .to_string(),
4337                    metadata: json!({"ok": false, "reason": "invalid_tier"}),
4338                });
4339            }
4340            None => {
4341                if project_id.is_some() {
4342                    MemoryTier::Project
4343                } else if session_id.is_some() {
4344                    MemoryTier::Session
4345                } else if allow_global {
4346                    MemoryTier::Global
4347                } else {
4348                    return Ok(ToolResult {
4349                        output: "memory_store requires a current session/project context or global memory enabled by policy"
4350                            .to_string(),
4351                        metadata: json!({"ok": false, "reason": "missing_scope"}),
4352                    });
4353                }
4354            }
4355        };
4356
4357        if matches!(tier, MemoryTier::Session) && session_id.is_none() {
4358            return Ok(ToolResult {
4359                output: "tier=session requires session_id".to_string(),
4360                metadata: json!({"ok": false, "reason": "missing_session_scope"}),
4361            });
4362        }
4363        if matches!(tier, MemoryTier::Project) && project_id.is_none() {
4364            return Ok(ToolResult {
4365                output: "tier=project requires project_id".to_string(),
4366                metadata: json!({"ok": false, "reason": "missing_project_scope"}),
4367            });
4368        }
4369        if matches!(tier, MemoryTier::Global) && !allow_global {
4370            return Ok(ToolResult {
4371                output: "tier=global requires allow_global=true".to_string(),
4372                metadata: json!({"ok": false, "reason": "global_scope_disabled"}),
4373            });
4374        }
4375
4376        let db_path = resolve_memory_db_path(&args);
4377        let manager = MemoryManager::new(&db_path).await?;
4378        let health = manager.embedding_health().await;
4379        if health.status != "ok" {
4380            return Ok(ToolResult {
4381                output: "memory embeddings unavailable; semantic memory store is disabled"
4382                    .to_string(),
4383                metadata: json!({
4384                    "ok": false,
4385                    "reason": "embeddings_unavailable",
4386                    "embedding_status": health.status,
4387                    "embedding_reason": health.reason,
4388                }),
4389            });
4390        }
4391
4392        let source = args
4393            .get("source")
4394            .and_then(|v| v.as_str())
4395            .map(str::trim)
4396            .filter(|s| !s.is_empty())
4397            .unwrap_or("agent_note")
4398            .to_string();
4399        let metadata = args.get("metadata").cloned();
4400
4401        let request = tandem_memory::types::StoreMessageRequest {
4402            content: content.to_string(),
4403            tier,
4404            session_id: session_id.clone(),
4405            project_id: project_id.clone(),
4406            source,
4407            source_path: None,
4408            source_mtime: None,
4409            source_size: None,
4410            source_hash: None,
4411            metadata,
4412        };
4413        let chunk_ids = manager.store_message(request).await?;
4414
4415        Ok(ToolResult {
4416            output: format!("stored {} chunk(s) in {} memory", chunk_ids.len(), tier),
4417            metadata: json!({
4418                "ok": true,
4419                "chunk_ids": chunk_ids,
4420                "count": chunk_ids.len(),
4421                "tier": tier.to_string(),
4422                "session_id": session_id,
4423                "project_id": project_id,
4424                "allow_global": allow_global,
4425                "embedding_status": health.status,
4426                "embedding_reason": health.reason,
4427                "db_path": db_path,
4428            }),
4429        })
4430    }
4431}
4432
4433struct MemoryListTool;
4434#[async_trait]
4435impl Tool for MemoryListTool {
4436    fn schema(&self) -> ToolSchema {
4437        tool_schema(
4438            "memory_list",
4439            "List stored memory chunks for auditing and knowledge-base browsing.",
4440            json!({
4441                "type":"object",
4442                "properties":{
4443                    "tier":{"type":"string","enum":["session","project","global","all"]},
4444                    "session_id":{"type":"string"},
4445                    "project_id":{"type":"string"},
4446                    "limit":{"type":"integer","minimum":1,"maximum":200},
4447                    "allow_global":{"type":"boolean"}
4448                }
4449            }),
4450        )
4451    }
4452
4453    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
4454        let session_id = memory_session_id(&args);
4455        let project_id = memory_project_id(&args);
4456        let allow_global = global_memory_enabled(&args);
4457        let limit = args
4458            .get("limit")
4459            .and_then(|v| v.as_i64())
4460            .unwrap_or(50)
4461            .clamp(1, 200) as usize;
4462
4463        let tier = args
4464            .get("tier")
4465            .and_then(|v| v.as_str())
4466            .map(|s| s.trim().to_ascii_lowercase())
4467            .unwrap_or_else(|| "all".to_string());
4468        if tier == "global" && !allow_global {
4469            return Ok(ToolResult {
4470                output: "tier=global requires allow_global=true".to_string(),
4471                metadata: json!({"ok": false, "reason": "global_scope_disabled"}),
4472            });
4473        }
4474        if session_id.is_none() && project_id.is_none() && tier != "global" && !allow_global {
4475            return Ok(ToolResult {
4476                output: "memory_list requires a current session/project context or global memory enabled by policy".to_string(),
4477                metadata: json!({"ok": false, "reason": "missing_scope"}),
4478            });
4479        }
4480
4481        let db_path = resolve_memory_db_path(&args);
4482        let manager = MemoryManager::new(&db_path).await?;
4483
4484        let mut chunks: Vec<tandem_memory::types::MemoryChunk> = Vec::new();
4485        match tier.as_str() {
4486            "session" => {
4487                let Some(sid) = session_id.as_deref() else {
4488                    return Ok(ToolResult {
4489                        output: "tier=session requires session_id".to_string(),
4490                        metadata: json!({"ok": false, "reason": "missing_session_scope"}),
4491                    });
4492                };
4493                chunks.extend(manager.db().get_session_chunks(sid).await?);
4494            }
4495            "project" => {
4496                let Some(pid) = project_id.as_deref() else {
4497                    return Ok(ToolResult {
4498                        output: "tier=project requires project_id".to_string(),
4499                        metadata: json!({"ok": false, "reason": "missing_project_scope"}),
4500                    });
4501                };
4502                chunks.extend(manager.db().get_project_chunks(pid).await?);
4503            }
4504            "global" => {
4505                chunks.extend(manager.db().get_global_chunks(limit as i64).await?);
4506            }
4507            "all" => {
4508                if let Some(sid) = session_id.as_deref() {
4509                    chunks.extend(manager.db().get_session_chunks(sid).await?);
4510                }
4511                if let Some(pid) = project_id.as_deref() {
4512                    chunks.extend(manager.db().get_project_chunks(pid).await?);
4513                }
4514                if allow_global {
4515                    chunks.extend(manager.db().get_global_chunks(limit as i64).await?);
4516                }
4517            }
4518            _ => {
4519                return Ok(ToolResult {
4520                    output: "memory_list tier must be one of: session, project, global, all"
4521                        .to_string(),
4522                    metadata: json!({"ok": false, "reason": "invalid_tier"}),
4523                });
4524            }
4525        }
4526
4527        chunks.sort_by(|a, b| b.created_at.cmp(&a.created_at));
4528        chunks.truncate(limit);
4529        let rows = chunks
4530            .iter()
4531            .map(|chunk| {
4532                json!({
4533                    "chunk_id": chunk.id,
4534                    "tier": chunk.tier.to_string(),
4535                    "session_id": chunk.session_id,
4536                    "project_id": chunk.project_id,
4537                    "source": chunk.source,
4538                    "content": chunk.content,
4539                    "created_at": chunk.created_at,
4540                    "metadata": chunk.metadata,
4541                })
4542            })
4543            .collect::<Vec<_>>();
4544
4545        Ok(ToolResult {
4546            output: serde_json::to_string_pretty(&rows).unwrap_or_default(),
4547            metadata: json!({
4548                "ok": true,
4549                "count": rows.len(),
4550                "limit": limit,
4551                "tier": tier,
4552                "session_id": session_id,
4553                "project_id": project_id,
4554                "allow_global": allow_global,
4555                "db_path": db_path,
4556            }),
4557        })
4558    }
4559}
4560
4561struct MemoryDeleteTool;
4562#[async_trait]
4563impl Tool for MemoryDeleteTool {
4564    fn schema(&self) -> ToolSchema {
4565        tool_schema(
4566            "memory_delete",
4567            "Delete a stored memory chunk from session/project/global memory within the current allowed scope.",
4568            json!({
4569                "type":"object",
4570                "properties":{
4571                    "chunk_id":{"type":"string"},
4572                    "id":{"type":"string"},
4573                    "tier":{"type":"string","enum":["session","project","global"]},
4574                    "session_id":{"type":"string"},
4575                    "project_id":{"type":"string"},
4576                    "allow_global":{"type":"boolean"}
4577                },
4578                "required":["chunk_id"]
4579            }),
4580        )
4581    }
4582
4583    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
4584        let chunk_id = args
4585            .get("chunk_id")
4586            .or_else(|| args.get("id"))
4587            .and_then(|v| v.as_str())
4588            .map(str::trim)
4589            .unwrap_or("");
4590        if chunk_id.is_empty() {
4591            return Ok(ToolResult {
4592                output: "memory_delete requires chunk_id".to_string(),
4593                metadata: json!({"ok": false, "reason": "missing_chunk_id"}),
4594            });
4595        }
4596
4597        let session_id = memory_session_id(&args);
4598        let project_id = memory_project_id(&args);
4599        let allow_global = global_memory_enabled(&args);
4600
4601        let tier = match args
4602            .get("tier")
4603            .and_then(|v| v.as_str())
4604            .map(|s| s.trim().to_ascii_lowercase())
4605        {
4606            Some(t) if t == "session" => MemoryTier::Session,
4607            Some(t) if t == "project" => MemoryTier::Project,
4608            Some(t) if t == "global" => MemoryTier::Global,
4609            Some(_) => {
4610                return Ok(ToolResult {
4611                    output: "memory_delete tier must be one of: session, project, global"
4612                        .to_string(),
4613                    metadata: json!({"ok": false, "reason": "invalid_tier"}),
4614                });
4615            }
4616            None => {
4617                if project_id.is_some() {
4618                    MemoryTier::Project
4619                } else if session_id.is_some() {
4620                    MemoryTier::Session
4621                } else if allow_global {
4622                    MemoryTier::Global
4623                } else {
4624                    return Ok(ToolResult {
4625                        output: "memory_delete requires a current session/project context or global memory enabled by policy".to_string(),
4626                        metadata: json!({"ok": false, "reason": "missing_scope"}),
4627                    });
4628                }
4629            }
4630        };
4631
4632        if matches!(tier, MemoryTier::Session) && session_id.is_none() {
4633            return Ok(ToolResult {
4634                output: "tier=session requires session_id".to_string(),
4635                metadata: json!({"ok": false, "reason": "missing_session_scope"}),
4636            });
4637        }
4638        if matches!(tier, MemoryTier::Project) && project_id.is_none() {
4639            return Ok(ToolResult {
4640                output: "tier=project requires project_id".to_string(),
4641                metadata: json!({"ok": false, "reason": "missing_project_scope"}),
4642            });
4643        }
4644        if matches!(tier, MemoryTier::Global) && !allow_global {
4645            return Ok(ToolResult {
4646                output: "tier=global requires allow_global=true".to_string(),
4647                metadata: json!({"ok": false, "reason": "global_scope_disabled"}),
4648            });
4649        }
4650
4651        let db_path = resolve_memory_db_path(&args);
4652        let manager = MemoryManager::new(&db_path).await?;
4653        let deleted = manager
4654            .db()
4655            .delete_chunk(tier, chunk_id, project_id.as_deref(), session_id.as_deref())
4656            .await?;
4657
4658        if deleted == 0 {
4659            return Ok(ToolResult {
4660                output: format!("memory chunk `{chunk_id}` not found in {tier} memory"),
4661                metadata: json!({
4662                    "ok": false,
4663                    "reason": "not_found",
4664                    "chunk_id": chunk_id,
4665                    "tier": tier.to_string(),
4666                    "session_id": session_id,
4667                    "project_id": project_id,
4668                    "allow_global": allow_global,
4669                    "db_path": db_path,
4670                }),
4671            });
4672        }
4673
4674        Ok(ToolResult {
4675            output: format!("deleted memory chunk `{chunk_id}` from {tier} memory"),
4676            metadata: json!({
4677                "ok": true,
4678                "deleted": true,
4679                "chunk_id": chunk_id,
4680                "count": deleted,
4681                "tier": tier.to_string(),
4682                "session_id": session_id,
4683                "project_id": project_id,
4684                "allow_global": allow_global,
4685                "db_path": db_path,
4686            }),
4687        })
4688    }
4689}
4690
4691fn resolve_memory_db_path(args: &Value) -> PathBuf {
4692    if let Some(path) = args
4693        .get("__memory_db_path")
4694        .and_then(|v| v.as_str())
4695        .map(str::trim)
4696        .filter(|s| !s.is_empty())
4697    {
4698        return PathBuf::from(path);
4699    }
4700    if let Ok(path) = std::env::var("TANDEM_MEMORY_DB_PATH") {
4701        let trimmed = path.trim();
4702        if !trimmed.is_empty() {
4703            return PathBuf::from(trimmed);
4704        }
4705    }
4706    if let Ok(state_dir) = std::env::var("TANDEM_STATE_DIR") {
4707        let trimmed = state_dir.trim();
4708        if !trimmed.is_empty() {
4709            return PathBuf::from(trimmed).join("memory.sqlite");
4710        }
4711    }
4712    if let Some(data_dir) = dirs::data_dir() {
4713        return data_dir.join("tandem").join("memory.sqlite");
4714    }
4715    PathBuf::from("memory.sqlite")
4716}
4717
4718#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4719enum MemoryVisibleScope {
4720    Session,
4721    Project,
4722    Global,
4723}
4724
4725fn parse_memory_visible_scope(raw: &str) -> Option<MemoryVisibleScope> {
4726    match raw.trim().to_ascii_lowercase().as_str() {
4727        "session" => Some(MemoryVisibleScope::Session),
4728        "project" | "workspace" => Some(MemoryVisibleScope::Project),
4729        "global" => Some(MemoryVisibleScope::Global),
4730        _ => None,
4731    }
4732}
4733
4734fn memory_visible_scope(args: &Value) -> MemoryVisibleScope {
4735    if let Some(scope) = args
4736        .get("__memory_max_visible_scope")
4737        .and_then(|v| v.as_str())
4738        .and_then(parse_memory_visible_scope)
4739    {
4740        return scope;
4741    }
4742    if let Ok(raw) = std::env::var("TANDEM_MEMORY_MAX_VISIBLE_SCOPE") {
4743        if let Some(scope) = parse_memory_visible_scope(&raw) {
4744            return scope;
4745        }
4746    }
4747    MemoryVisibleScope::Global
4748}
4749
4750fn memory_session_id(args: &Value) -> Option<String> {
4751    args.get("session_id")
4752        .or_else(|| args.get("__session_id"))
4753        .and_then(|v| v.as_str())
4754        .map(str::trim)
4755        .filter(|s| !s.is_empty())
4756        .map(ToString::to_string)
4757}
4758
4759fn memory_project_id(args: &Value) -> Option<String> {
4760    args.get("project_id")
4761        .or_else(|| args.get("__project_id"))
4762        .and_then(|v| v.as_str())
4763        .map(str::trim)
4764        .filter(|s| !s.is_empty())
4765        .map(ToString::to_string)
4766}
4767
4768fn global_memory_enabled(args: &Value) -> bool {
4769    if memory_visible_scope(args) != MemoryVisibleScope::Global {
4770        return false;
4771    }
4772    if let Some(explicit) = args.get("allow_global").and_then(|v| v.as_bool()) {
4773        return explicit;
4774    }
4775    match std::env::var("TANDEM_ENABLE_GLOBAL_MEMORY") {
4776        Ok(raw) => !matches!(
4777            raw.trim().to_ascii_lowercase().as_str(),
4778            "0" | "false" | "no" | "off"
4779        ),
4780        Err(_) => true,
4781    }
4782}
4783
4784struct SkillTool;
4785#[async_trait]
4786impl Tool for SkillTool {
4787    fn schema(&self) -> ToolSchema {
4788        tool_schema(
4789            "skill",
4790            "List or load installed Tandem skills. Call without name to list available skills; provide name to load full SKILL.md content.",
4791            json!({"type":"object","properties":{"name":{"type":"string"}}}),
4792        )
4793    }
4794    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
4795        let workspace_root = std::env::current_dir().ok();
4796        let service = SkillService::for_workspace(workspace_root);
4797        let requested = args["name"].as_str().map(str::trim).unwrap_or("");
4798        let allowed_skills = parse_allowed_skills(&args);
4799
4800        if requested.is_empty() {
4801            let mut skills = service.list_skills().unwrap_or_default();
4802            if let Some(allowed) = &allowed_skills {
4803                skills.retain(|s| allowed.contains(&s.name));
4804            }
4805            if skills.is_empty() {
4806                return Ok(ToolResult {
4807                    output: "No skills available.".to_string(),
4808                    metadata: json!({"count": 0, "skills": []}),
4809                });
4810            }
4811            let mut lines = vec![
4812                "Available Tandem skills:".to_string(),
4813                "<available_skills>".to_string(),
4814            ];
4815            for skill in &skills {
4816                lines.push("  <skill>".to_string());
4817                lines.push(format!("    <name>{}</name>", skill.name));
4818                lines.push(format!(
4819                    "    <description>{}</description>",
4820                    escape_xml_text(&skill.description)
4821                ));
4822                lines.push(format!("    <location>{}</location>", skill.path));
4823                lines.push("  </skill>".to_string());
4824            }
4825            lines.push("</available_skills>".to_string());
4826            return Ok(ToolResult {
4827                output: lines.join("\n"),
4828                metadata: json!({"count": skills.len(), "skills": skills}),
4829            });
4830        }
4831
4832        if let Some(allowed) = &allowed_skills {
4833            if !allowed.contains(requested) {
4834                let mut allowed_list = allowed.iter().cloned().collect::<Vec<_>>();
4835                allowed_list.sort();
4836                return Ok(ToolResult {
4837                    output: format!(
4838                        "Skill \"{}\" is not enabled for this agent. Enabled skills: {}",
4839                        requested,
4840                        allowed_list.join(", ")
4841                    ),
4842                    metadata: json!({"name": requested, "enabled": allowed_list}),
4843                });
4844            }
4845        }
4846
4847        let loaded = service.load_skill(requested).map_err(anyhow::Error::msg)?;
4848        let Some(skill) = loaded else {
4849            let available = service
4850                .list_skills()
4851                .unwrap_or_default()
4852                .into_iter()
4853                .map(|s| s.name)
4854                .collect::<Vec<_>>();
4855            return Ok(ToolResult {
4856                output: format!(
4857                    "Skill \"{}\" not found. Available skills: {}",
4858                    requested,
4859                    if available.is_empty() {
4860                        "none".to_string()
4861                    } else {
4862                        available.join(", ")
4863                    }
4864                ),
4865                metadata: json!({"name": requested, "matches": [], "available": available}),
4866            });
4867        };
4868
4869        let files = skill
4870            .files
4871            .iter()
4872            .map(|f| format!("<file>{}</file>", f))
4873            .collect::<Vec<_>>()
4874            .join("\n");
4875        let output = [
4876            format!("<skill_content name=\"{}\">", skill.info.name),
4877            format!("# Skill: {}", skill.info.name),
4878            String::new(),
4879            skill.content.trim().to_string(),
4880            String::new(),
4881            format!("Base directory for this skill: {}", skill.base_dir),
4882            "Relative paths in this skill are resolved from this base directory.".to_string(),
4883            "Note: file list is sampled.".to_string(),
4884            String::new(),
4885            "<skill_files>".to_string(),
4886            files,
4887            "</skill_files>".to_string(),
4888            "</skill_content>".to_string(),
4889        ]
4890        .join("\n");
4891        Ok(ToolResult {
4892            output,
4893            metadata: json!({
4894                "name": skill.info.name,
4895                "dir": skill.base_dir,
4896                "path": skill.info.path
4897            }),
4898        })
4899    }
4900}
4901
4902fn escape_xml_text(input: &str) -> String {
4903    input
4904        .replace('&', "&amp;")
4905        .replace('<', "&lt;")
4906        .replace('>', "&gt;")
4907}
4908
4909fn parse_allowed_skills(args: &Value) -> Option<HashSet<String>> {
4910    let values = args
4911        .get("allowed_skills")
4912        .or_else(|| args.get("allowedSkills"))
4913        .and_then(|v| v.as_array())?;
4914    let out = values
4915        .iter()
4916        .filter_map(|v| v.as_str())
4917        .map(str::trim)
4918        .filter(|s| !s.is_empty())
4919        .map(ToString::to_string)
4920        .collect::<HashSet<_>>();
4921    Some(out)
4922}
4923
4924struct ApplyPatchTool;
4925#[async_trait]
4926impl Tool for ApplyPatchTool {
4927    fn schema(&self) -> ToolSchema {
4928        tool_schema_with_capabilities(
4929            "apply_patch",
4930            "Apply a Codex-style patch in a git workspace, or validate patch text when git patching is unavailable",
4931            json!({"type":"object","properties":{"patchText":{"type":"string"}}}),
4932            apply_patch_capabilities(),
4933        )
4934    }
4935    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
4936        let patch = args["patchText"].as_str().unwrap_or("");
4937        let has_begin = patch.contains("*** Begin Patch");
4938        let has_end = patch.contains("*** End Patch");
4939        let patch_paths = extract_apply_patch_paths(patch);
4940        let file_ops = patch_paths.len();
4941        let valid = has_begin && has_end && file_ops > 0;
4942        if !valid {
4943            return Ok(ToolResult {
4944                output: "Invalid patch format. Expected Begin/End markers and at least one file operation."
4945                    .to_string(),
4946                metadata: json!({"valid": false, "fileOps": file_ops}),
4947            });
4948        }
4949        let workspace_root =
4950            workspace_root_from_args(&args).unwrap_or_else(|| effective_cwd_from_args(&args));
4951        let git_root = resolve_git_root_for_dir(&workspace_root).await;
4952        if let Some(git_root) = git_root {
4953            let denied_paths = patch_paths
4954                .iter()
4955                .filter_map(|rel| {
4956                    let resolved = git_root.join(rel);
4957                    if is_within_workspace_root(&resolved, &workspace_root) {
4958                        None
4959                    } else {
4960                        Some(rel.clone())
4961                    }
4962                })
4963                .collect::<Vec<_>>();
4964            if !denied_paths.is_empty() {
4965                return Ok(ToolResult {
4966                    output: format!(
4967                        "patch denied by workspace policy for paths: {}",
4968                        denied_paths.join(", ")
4969                    ),
4970                    metadata: json!({
4971                        "valid": true,
4972                        "applied": false,
4973                        "reason": "path_outside_workspace",
4974                        "paths": patch_paths
4975                    }),
4976                });
4977            }
4978            let tmp_name = format!(
4979                "tandem-apply-patch-{}-{}.patch",
4980                std::process::id(),
4981                now_millis()
4982            );
4983            let patch_path = std::env::temp_dir().join(tmp_name);
4984            fs::write(&patch_path, patch).await?;
4985            let output = Command::new("git")
4986                .current_dir(&git_root)
4987                .arg("apply")
4988                .arg("--3way")
4989                .arg("--recount")
4990                .arg("--whitespace=nowarn")
4991                .arg(&patch_path)
4992                .output()
4993                .await?;
4994            let _ = fs::remove_file(&patch_path).await;
4995            let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
4996            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
4997            let ok = output.status.success();
4998            return Ok(ToolResult {
4999                output: if ok {
5000                    if stdout.is_empty() {
5001                        "ok".to_string()
5002                    } else {
5003                        stdout.clone()
5004                    }
5005                } else if stderr.is_empty() {
5006                    "git apply failed".to_string()
5007                } else {
5008                    stderr.clone()
5009                },
5010                metadata: json!({
5011                    "valid": true,
5012                    "applied": ok,
5013                    "paths": patch_paths,
5014                    "git_root": git_root.to_string_lossy(),
5015                    "stdout": stdout,
5016                    "stderr": stderr
5017                }),
5018            });
5019        }
5020        Ok(ToolResult {
5021            output: "Patch format validated, but no git workspace was detected. Use `edit` for existing files or `write` for new files in this workspace."
5022                .to_string(),
5023            metadata: json!({
5024                "valid": true,
5025                "applied": false,
5026                "reason": "git_workspace_unavailable",
5027                "paths": patch_paths
5028            }),
5029        })
5030    }
5031}
5032
5033fn extract_apply_patch_paths(patch: &str) -> Vec<String> {
5034    let mut paths = Vec::new();
5035    for line in patch.lines() {
5036        let trimmed = line.trim();
5037        let marker = if let Some(value) = trimmed.strip_prefix("*** Add File: ") {
5038            Some(value)
5039        } else if let Some(value) = trimmed.strip_prefix("*** Update File: ") {
5040            Some(value)
5041        } else {
5042            trimmed.strip_prefix("*** Delete File: ")
5043        };
5044        let Some(path) = marker.map(str::trim).filter(|value| !value.is_empty()) else {
5045            continue;
5046        };
5047        if !paths.iter().any(|existing| existing == path) {
5048            paths.push(path.to_string());
5049        }
5050    }
5051    paths
5052}
5053
5054async fn resolve_git_root_for_dir(dir: &Path) -> Option<PathBuf> {
5055    let output = Command::new("git")
5056        .current_dir(dir)
5057        .arg("rev-parse")
5058        .arg("--show-toplevel")
5059        .stdout(Stdio::piped())
5060        .stderr(Stdio::null())
5061        .output()
5062        .await
5063        .ok()?;
5064    if !output.status.success() {
5065        return None;
5066    }
5067    let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
5068    if root.is_empty() {
5069        None
5070    } else {
5071        Some(PathBuf::from(root))
5072    }
5073}
5074
5075fn now_millis() -> u128 {
5076    std::time::SystemTime::now()
5077        .duration_since(std::time::UNIX_EPOCH)
5078        .map(|value| value.as_millis())
5079        .unwrap_or(0)
5080}
5081
5082struct BatchTool;
5083#[async_trait]
5084impl Tool for BatchTool {
5085    fn schema(&self) -> ToolSchema {
5086        tool_schema(
5087            "batch",
5088            "Execute multiple tool calls sequentially",
5089            json!({
5090                "type":"object",
5091                "properties":{
5092                    "tool_calls":{
5093                        "type":"array",
5094                        "items":{
5095                            "type":"object",
5096                            "properties":{
5097                                "tool":{"type":"string"},
5098                                "name":{"type":"string"},
5099                                "args":{"type":"object"}
5100                            }
5101                        }
5102                    }
5103                }
5104            }),
5105        )
5106    }
5107    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
5108        self.execute_with_cancel(args, CancellationToken::new())
5109            .await
5110    }
5111
5112    async fn execute_with_cancel(
5113        &self,
5114        args: Value,
5115        cancel: CancellationToken,
5116    ) -> anyhow::Result<ToolResult> {
5117        self.execute_with_progress(args, cancel, None).await
5118    }
5119
5120    async fn execute_with_progress(
5121        &self,
5122        args: Value,
5123        cancel: CancellationToken,
5124        progress: Option<SharedToolProgressSink>,
5125    ) -> anyhow::Result<ToolResult> {
5126        let calls = args["tool_calls"].as_array().cloned().unwrap_or_default();
5127        let registry = ToolRegistry::new();
5128        let mut outputs = Vec::new();
5129        for call in calls.iter().take(20) {
5130            if cancel.is_cancelled() {
5131                break;
5132            }
5133            let Some(tool) = resolve_batch_call_tool_name(call) else {
5134                continue;
5135            };
5136            if tool.is_empty() || tool == "batch" {
5137                continue;
5138            }
5139            let call_args = call.get("args").cloned().unwrap_or_else(|| json!({}));
5140            let mut result = registry
5141                .execute_with_cancel_and_progress(
5142                    &tool,
5143                    call_args.clone(),
5144                    cancel.clone(),
5145                    progress.clone(),
5146                )
5147                .await?;
5148            if result.output.starts_with("Unknown tool:") {
5149                if let Some(fallback_name) = call
5150                    .get("name")
5151                    .and_then(|v| v.as_str())
5152                    .map(str::trim)
5153                    .filter(|s| !s.is_empty() && *s != tool)
5154                {
5155                    result = registry
5156                        .execute_with_cancel_and_progress(
5157                            fallback_name,
5158                            call_args,
5159                            cancel.clone(),
5160                            progress.clone(),
5161                        )
5162                        .await?;
5163                }
5164            }
5165            outputs.push(json!({
5166                "tool": tool,
5167                "output": result.output,
5168                "metadata": result.metadata
5169            }));
5170        }
5171        let count = outputs.len();
5172        Ok(ToolResult {
5173            output: serde_json::to_string_pretty(&outputs).unwrap_or_default(),
5174            metadata: json!({"count": count}),
5175        })
5176    }
5177}
5178
5179struct LspTool;
5180#[async_trait]
5181impl Tool for LspTool {
5182    fn schema(&self) -> ToolSchema {
5183        tool_schema(
5184            "lsp",
5185            "LSP-like workspace diagnostics and symbol operations",
5186            json!({"type":"object","properties":{"operation":{"type":"string"},"filePath":{"type":"string"},"symbol":{"type":"string"},"query":{"type":"string"}}}),
5187        )
5188    }
5189    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
5190        let operation = args["operation"].as_str().unwrap_or("symbols");
5191        let workspace_root =
5192            workspace_root_from_args(&args).unwrap_or_else(|| effective_cwd_from_args(&args));
5193        let output = match operation {
5194            "diagnostics" => {
5195                let path = args["filePath"].as_str().unwrap_or("");
5196                match resolve_tool_path(path, &args) {
5197                    Some(resolved_path) => {
5198                        diagnostics_for_path(&resolved_path.to_string_lossy()).await
5199                    }
5200                    None => "missing or unsafe filePath".to_string(),
5201                }
5202            }
5203            "definition" => {
5204                let symbol = args["symbol"].as_str().unwrap_or("");
5205                find_symbol_definition(symbol, &workspace_root).await
5206            }
5207            "references" => {
5208                let symbol = args["symbol"].as_str().unwrap_or("");
5209                find_symbol_references(symbol, &workspace_root).await
5210            }
5211            _ => {
5212                let query = args["query"]
5213                    .as_str()
5214                    .or_else(|| args["symbol"].as_str())
5215                    .unwrap_or("");
5216                list_symbols(query, &workspace_root).await
5217            }
5218        };
5219        Ok(ToolResult {
5220            output,
5221            metadata: json!({"operation": operation, "workspace_root": workspace_root.to_string_lossy()}),
5222        })
5223    }
5224}
5225
5226#[allow(dead_code)]
5227fn _safe_path(path: &str) -> PathBuf {
5228    PathBuf::from(path)
5229}
5230
5231static TODO_SEQ: AtomicU64 = AtomicU64::new(1);
5232
5233fn normalize_todos(items: Vec<Value>) -> Vec<Value> {
5234    items
5235        .into_iter()
5236        .filter_map(|item| {
5237            let obj = item.as_object()?;
5238            let content = obj
5239                .get("content")
5240                .and_then(|v| v.as_str())
5241                .or_else(|| obj.get("text").and_then(|v| v.as_str()))
5242                .unwrap_or("")
5243                .trim()
5244                .to_string();
5245            if content.is_empty() {
5246                return None;
5247            }
5248            let id = obj
5249                .get("id")
5250                .and_then(|v| v.as_str())
5251                .filter(|s| !s.trim().is_empty())
5252                .map(ToString::to_string)
5253                .unwrap_or_else(|| {
5254                    format!("todo-{}", TODO_SEQ.fetch_add(1, AtomicOrdering::Relaxed))
5255                });
5256            let status = obj
5257                .get("status")
5258                .and_then(|v| v.as_str())
5259                .filter(|s| !s.trim().is_empty())
5260                .map(ToString::to_string)
5261                .unwrap_or_else(|| "pending".to_string());
5262            Some(json!({"id": id, "content": content, "status": status}))
5263        })
5264        .collect()
5265}
5266
5267async fn diagnostics_for_path(path: &str) -> String {
5268    let Ok(content) = fs::read_to_string(path).await else {
5269        return "File not found".to_string();
5270    };
5271    let mut issues = Vec::new();
5272    let mut balance = 0i64;
5273    for (idx, line) in content.lines().enumerate() {
5274        for ch in line.chars() {
5275            if ch == '{' {
5276                balance += 1;
5277            } else if ch == '}' {
5278                balance -= 1;
5279            }
5280        }
5281        if line.contains("TODO") {
5282            issues.push(format!("{path}:{}: TODO marker", idx + 1));
5283        }
5284    }
5285    if balance != 0 {
5286        issues.push(format!("{path}:1: Unbalanced braces"));
5287    }
5288    if issues.is_empty() {
5289        "No diagnostics.".to_string()
5290    } else {
5291        issues.join("\n")
5292    }
5293}
5294
5295async fn list_symbols(query: &str, root: &Path) -> String {
5296    let query = query.to_lowercase();
5297    let rust_fn = Regex::new(r"^\s*(pub\s+)?(async\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)")
5298        .unwrap_or_else(|_| Regex::new("$^").expect("regex"));
5299    let mut out = Vec::new();
5300    for entry in WalkBuilder::new(root).build().flatten() {
5301        if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
5302            continue;
5303        }
5304        let path = entry.path();
5305        let ext = path.extension().and_then(|v| v.to_str()).unwrap_or("");
5306        if !matches!(ext, "rs" | "ts" | "tsx" | "js" | "jsx" | "py") {
5307            continue;
5308        }
5309        if let Ok(content) = fs::read_to_string(path).await {
5310            for (idx, line) in content.lines().enumerate() {
5311                if let Some(captures) = rust_fn.captures(line) {
5312                    let name = captures
5313                        .get(3)
5314                        .map(|m| m.as_str().to_string())
5315                        .unwrap_or_default();
5316                    if query.is_empty() || name.to_lowercase().contains(&query) {
5317                        out.push(format!("{}:{}:fn {}", path.display(), idx + 1, name));
5318                        if out.len() >= 100 {
5319                            return out.join("\n");
5320                        }
5321                    }
5322                }
5323            }
5324        }
5325    }
5326    out.join("\n")
5327}
5328
5329async fn find_symbol_definition(symbol: &str, root: &Path) -> String {
5330    if symbol.trim().is_empty() {
5331        return "missing symbol".to_string();
5332    }
5333    let listed = list_symbols(symbol, root).await;
5334    listed
5335        .lines()
5336        .find(|line| line.ends_with(&format!("fn {symbol}")))
5337        .map(ToString::to_string)
5338        .unwrap_or_else(|| "symbol not found".to_string())
5339}
5340
5341#[cfg(test)]
5342mod tests {
5343    use super::*;
5344    use std::collections::HashSet;
5345    use std::path::PathBuf;
5346    use std::sync::{Arc, Mutex, OnceLock};
5347    use tandem_types::ToolProgressSink;
5348    use tempfile::TempDir;
5349    use tokio::fs;
5350    use tokio_util::sync::CancellationToken;
5351
5352    #[derive(Clone, Default)]
5353    struct RecordingProgressSink {
5354        events: Arc<Mutex<Vec<ToolProgressEvent>>>,
5355    }
5356
5357    impl ToolProgressSink for RecordingProgressSink {
5358        fn publish(&self, event: ToolProgressEvent) {
5359            self.events.lock().expect("progress lock").push(event);
5360        }
5361    }
5362
5363    struct TestTool {
5364        schema: ToolSchema,
5365    }
5366
5367    #[async_trait]
5368    impl Tool for TestTool {
5369        fn schema(&self) -> ToolSchema {
5370            self.schema.clone()
5371        }
5372
5373        async fn execute(&self, _args: Value) -> anyhow::Result<ToolResult> {
5374            Ok(ToolResult {
5375                output: "ok".to_string(),
5376                metadata: json!({}),
5377            })
5378        }
5379
5380        async fn execute_with_cancel(
5381            &self,
5382            args: Value,
5383            _cancel: CancellationToken,
5384        ) -> anyhow::Result<ToolResult> {
5385            self.execute(args).await
5386        }
5387    }
5388
5389    fn search_env_lock() -> &'static Mutex<()> {
5390        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
5391        LOCK.get_or_init(|| Mutex::new(()))
5392    }
5393
5394    fn clear_search_env() {
5395        std::env::remove_var("TANDEM_SEARCH_BACKEND");
5396        std::env::remove_var("TANDEM_SEARCH_URL");
5397        std::env::remove_var("TANDEM_SEARXNG_URL");
5398        std::env::remove_var("TANDEM_SEARXNG_ENGINES");
5399        std::env::remove_var("TANDEM_SEARCH_TIMEOUT_MS");
5400        std::env::remove_var("TANDEM_EXA_API_KEY");
5401        std::env::remove_var("TANDEM_EXA_SEARCH_API_KEY");
5402        std::env::remove_var("EXA_API_KEY");
5403        std::env::remove_var("TANDEM_BRAVE_SEARCH_API_KEY");
5404        std::env::remove_var("BRAVE_SEARCH_API_KEY");
5405    }
5406
5407    #[test]
5408    fn validator_rejects_array_without_items() {
5409        let schemas = vec![ToolSchema::new(
5410            "bad",
5411            "bad schema",
5412            json!({
5413                "type":"object",
5414                "properties":{"todos":{"type":"array"}}
5415            }),
5416        )];
5417        let err = validate_tool_schemas(&schemas).expect_err("expected schema validation failure");
5418        assert_eq!(err.tool_name, "bad");
5419        assert!(err.path.contains("properties.todos"));
5420    }
5421
5422    #[tokio::test]
5423    async fn registry_schemas_are_unique_and_valid() {
5424        let registry = ToolRegistry::new();
5425        let schemas = registry.list().await;
5426        validate_tool_schemas(&schemas).expect("registry tool schemas should validate");
5427        let unique = schemas
5428            .iter()
5429            .map(|schema| schema.name.as_str())
5430            .collect::<HashSet<_>>();
5431        assert_eq!(
5432            unique.len(),
5433            schemas.len(),
5434            "tool schemas must be unique by name"
5435        );
5436    }
5437
5438    #[tokio::test]
5439    async fn core_tool_schemas_include_expected_capabilities() {
5440        let registry = ToolRegistry::new();
5441        let schemas = registry.list().await;
5442        let schema_by_name = schemas
5443            .iter()
5444            .map(|schema| (schema.name.as_str(), schema))
5445            .collect::<HashMap<_, _>>();
5446
5447        let read = schema_by_name.get("read").expect("read tool");
5448        assert!(read.capabilities.reads_workspace);
5449        assert!(read.capabilities.preferred_for_discovery);
5450        assert_eq!(
5451            read.capabilities.effects,
5452            vec![tandem_types::ToolEffect::Read]
5453        );
5454
5455        let write = schema_by_name.get("write").expect("write tool");
5456        assert!(write.capabilities.writes_workspace);
5457        assert!(write.capabilities.requires_verification);
5458        assert_eq!(
5459            write.capabilities.effects,
5460            vec![tandem_types::ToolEffect::Write]
5461        );
5462
5463        let grep = schema_by_name.get("grep").expect("grep tool");
5464        assert!(grep.capabilities.reads_workspace);
5465        assert!(grep.capabilities.preferred_for_discovery);
5466        assert_eq!(
5467            grep.capabilities.effects,
5468            vec![tandem_types::ToolEffect::Search]
5469        );
5470
5471        let bash = schema_by_name.get("bash").expect("bash tool");
5472        assert!(bash.capabilities.destructive);
5473        assert!(bash.capabilities.network_access);
5474        assert_eq!(
5475            bash.capabilities.effects,
5476            vec![tandem_types::ToolEffect::Execute]
5477        );
5478
5479        let webfetch = schema_by_name.get("webfetch").expect("webfetch tool");
5480        assert!(webfetch.capabilities.network_access);
5481        assert!(webfetch.capabilities.preferred_for_discovery);
5482        assert_eq!(
5483            webfetch.capabilities.effects,
5484            vec![tandem_types::ToolEffect::Fetch]
5485        );
5486
5487        let apply_patch = schema_by_name.get("apply_patch").expect("apply_patch tool");
5488        assert!(apply_patch.capabilities.reads_workspace);
5489        assert!(apply_patch.capabilities.writes_workspace);
5490        assert!(apply_patch.capabilities.requires_verification);
5491        assert_eq!(
5492            apply_patch.capabilities.effects,
5493            vec![tandem_types::ToolEffect::Patch]
5494        );
5495    }
5496
5497    fn grep_args(root: &Path, pattern: &str) -> Value {
5498        let root = root.to_string_lossy().to_string();
5499        json!({
5500            "pattern": pattern,
5501            "path": root.clone(),
5502            "__workspace_root": root.clone(),
5503            "__effective_cwd": root,
5504        })
5505    }
5506
5507    #[tokio::test]
5508    async fn grep_tool_reports_matches_while_skipping_ignored_and_binary_paths() {
5509        let tempdir = TempDir::new().expect("tempdir");
5510        let root = tempdir.path();
5511        let visible = root.join("src").join("nested").join("notes.txt");
5512        let ignored = root.join(".tandem").join("private").join("secret.txt");
5513        let binary = root.join("binary.bin");
5514
5515        std::fs::create_dir_all(visible.parent().expect("visible parent"))
5516            .expect("create visible dir");
5517        std::fs::create_dir_all(ignored.parent().expect("ignored parent"))
5518            .expect("create ignored dir");
5519        std::fs::write(&visible, "first line\nneedle here\nlast line").expect("write visible file");
5520        std::fs::write(&ignored, "needle should stay hidden").expect("write ignored file");
5521        std::fs::write(&binary, b"\0needle after null\n").expect("write binary file");
5522
5523        let tool = GrepTool;
5524        let result = tool
5525            .execute(grep_args(root, "needle"))
5526            .await
5527            .expect("grep result");
5528
5529        assert_eq!(result.metadata["count"], json!(1));
5530        assert_eq!(
5531            result.output,
5532            format!("{}:2:needle here", visible.display())
5533        );
5534        assert!(!result.output.contains(".tandem/private/secret.txt"));
5535    }
5536
5537    #[tokio::test]
5538    async fn grep_tool_streams_chunk_and_done_events() {
5539        let tempdir = TempDir::new().expect("tempdir");
5540        let root = tempdir.path();
5541        let first = root.join("a.txt");
5542        let second = root.join("b.txt");
5543
5544        std::fs::write(
5545            &first,
5546            [
5547                "needle a1",
5548                "needle a2",
5549                "needle a3",
5550                "needle a4",
5551                "needle a5",
5552                "needle a6",
5553            ]
5554            .join("\n"),
5555        )
5556        .expect("write first file");
5557        std::fs::write(
5558            &second,
5559            [
5560                "needle b1",
5561                "needle b2",
5562                "needle b3",
5563                "needle b4",
5564                "needle b5",
5565                "needle b6",
5566            ]
5567            .join("\n"),
5568        )
5569        .expect("write second file");
5570
5571        let sink = RecordingProgressSink::default();
5572        let events = Arc::clone(&sink.events);
5573        let progress: SharedToolProgressSink = Arc::new(sink);
5574
5575        let tool = GrepTool;
5576        let result = tool
5577            .execute_with_progress(
5578                grep_args(root, "needle"),
5579                CancellationToken::new(),
5580                Some(progress),
5581            )
5582            .await
5583            .expect("grep result");
5584
5585        assert_eq!(result.metadata["count"], json!(12));
5586        let lines = result.output.lines().collect::<Vec<_>>();
5587        assert_eq!(lines.len(), 12);
5588        assert!(lines[0].starts_with(&first.display().to_string()));
5589        assert!(lines[11].starts_with(&second.display().to_string()));
5590
5591        let events = events.lock().expect("events").clone();
5592        assert!(!events.is_empty());
5593        assert!(events
5594            .iter()
5595            .any(|event| event.event_type == "tool.search.chunk"));
5596        let done = events
5597            .iter()
5598            .rev()
5599            .find(|event| event.event_type == "tool.search.done")
5600            .expect("done event");
5601        assert_eq!(done.properties["count"], json!(12));
5602        assert_eq!(done.properties["tool"], json!("grep"));
5603    }
5604
5605    #[tokio::test]
5606    async fn grep_tool_caps_results_at_100_hits() {
5607        let tempdir = TempDir::new().expect("tempdir");
5608        let root = tempdir.path();
5609        let source = root.join("many.txt");
5610        let lines = (1..=120)
5611            .map(|idx| format!("match line {}", idx))
5612            .collect::<Vec<_>>()
5613            .join("\n");
5614        std::fs::write(&source, lines).expect("write source file");
5615
5616        let tool = GrepTool;
5617        let result = tool
5618            .execute(grep_args(root, "match"))
5619            .await
5620            .expect("grep result");
5621
5622        assert_eq!(result.metadata["count"], json!(100));
5623        assert_eq!(result.output.lines().count(), 100);
5624        assert!(result.output.contains("match line 100"));
5625        assert!(!result.output.contains("match line 101"));
5626    }
5627
5628    #[tokio::test]
5629    async fn grep_tool_rejects_invalid_regex_patterns() {
5630        let tempdir = TempDir::new().expect("tempdir");
5631        let root = tempdir.path();
5632        std::fs::write(root.join("notes.txt"), "needle").expect("write file");
5633
5634        let tool = GrepTool;
5635        let err = tool.execute(grep_args(root, "(")).await;
5636
5637        assert!(err.is_err(), "expected invalid regex to fail");
5638    }
5639
5640    #[tokio::test]
5641    async fn mcp_server_names_returns_unique_sorted_names() {
5642        let registry = ToolRegistry::new();
5643        registry
5644            .register_tool(
5645                "mcp.notion.search_pages".to_string(),
5646                Arc::new(TestTool {
5647                    schema: ToolSchema::new("mcp.notion.search_pages", "search", json!({})),
5648                }),
5649            )
5650            .await;
5651        registry
5652            .register_tool(
5653                "mcp.github.list_prs".to_string(),
5654                Arc::new(TestTool {
5655                    schema: ToolSchema::new("mcp.github.list_prs", "list", json!({})),
5656                }),
5657            )
5658            .await;
5659        registry
5660            .register_tool(
5661                "mcp.github.get_pr".to_string(),
5662                Arc::new(TestTool {
5663                    schema: ToolSchema::new("mcp.github.get_pr", "get", json!({})),
5664                }),
5665            )
5666            .await;
5667
5668        let servers = registry.mcp_server_names().await;
5669        assert_eq!(servers, vec!["github".to_string(), "notion".to_string()]);
5670    }
5671
5672    #[tokio::test]
5673    async fn unregister_by_prefix_removes_index_vectors_for_removed_tools() {
5674        let registry = ToolRegistry::new();
5675        registry
5676            .register_tool(
5677                "mcp.test.search".to_string(),
5678                Arc::new(TestTool {
5679                    schema: ToolSchema::new("mcp.test.search", "search", json!({})),
5680                }),
5681            )
5682            .await;
5683        registry
5684            .register_tool(
5685                "mcp.test.get".to_string(),
5686                Arc::new(TestTool {
5687                    schema: ToolSchema::new("mcp.test.get", "get", json!({})),
5688                }),
5689            )
5690            .await;
5691
5692        registry
5693            .tool_vectors
5694            .write()
5695            .await
5696            .insert("mcp.test.search".to_string(), vec![1.0, 0.0, 0.0]);
5697        registry
5698            .tool_vectors
5699            .write()
5700            .await
5701            .insert("mcp.test.get".to_string(), vec![0.0, 1.0, 0.0]);
5702
5703        let removed = registry.unregister_by_prefix("mcp.test.").await;
5704        assert_eq!(removed, 2);
5705        let vectors = registry.tool_vectors.read().await;
5706        assert!(!vectors.contains_key("mcp.test.search"));
5707        assert!(!vectors.contains_key("mcp.test.get"));
5708    }
5709
5710    #[test]
5711    fn websearch_query_extraction_accepts_aliases_and_nested_shapes() {
5712        let direct = json!({"query":"meaning of life"});
5713        assert_eq!(
5714            extract_websearch_query(&direct).as_deref(),
5715            Some("meaning of life")
5716        );
5717
5718        let alias = json!({"q":"hello"});
5719        assert_eq!(extract_websearch_query(&alias).as_deref(), Some("hello"));
5720
5721        let nested = json!({"arguments":{"search_query":"rust tokio"}});
5722        assert_eq!(
5723            extract_websearch_query(&nested).as_deref(),
5724            Some("rust tokio")
5725        );
5726
5727        let as_string = json!("find docs");
5728        assert_eq!(
5729            extract_websearch_query(&as_string).as_deref(),
5730            Some("find docs")
5731        );
5732
5733        let malformed = json!({"query":"websearch query</arg_key><arg_value>taj card what is it benefits how to apply</arg_value>"});
5734        assert_eq!(
5735            extract_websearch_query(&malformed).as_deref(),
5736            Some("taj card what is it benefits how to apply")
5737        );
5738    }
5739
5740    #[test]
5741    fn websearch_limit_extraction_clamps_and_reads_nested_fields() {
5742        assert_eq!(extract_websearch_limit(&json!({"limit": 100})), Some(10));
5743        assert_eq!(
5744            extract_websearch_limit(&json!({"arguments":{"numResults": 0}})),
5745            Some(1)
5746        );
5747        assert_eq!(
5748            extract_websearch_limit(&json!({"input":{"num_results": 6}})),
5749            Some(6)
5750        );
5751    }
5752
5753    #[test]
5754    fn search_backend_defaults_to_searxng_when_configured() {
5755        let _guard = search_env_lock().lock().expect("env lock");
5756        clear_search_env();
5757        std::env::set_var("TANDEM_SEARXNG_URL", "http://localhost:8080");
5758
5759        let backend = SearchBackend::from_env();
5760
5761        match backend {
5762            SearchBackend::Searxng { base_url, .. } => {
5763                assert_eq!(base_url, "http://localhost:8080");
5764            }
5765            other => panic!("expected searxng backend, got {other:?}"),
5766        }
5767
5768        clear_search_env();
5769    }
5770
5771    #[test]
5772    fn search_backend_defaults_to_tandem_when_search_url_configured() {
5773        let _guard = search_env_lock().lock().expect("env lock");
5774        clear_search_env();
5775        std::env::set_var("TANDEM_SEARCH_URL", "https://search.tandem.ac");
5776
5777        let backend = SearchBackend::from_env();
5778
5779        match backend {
5780            SearchBackend::Tandem { base_url, .. } => {
5781                assert_eq!(base_url, "https://search.tandem.ac");
5782            }
5783            other => panic!("expected tandem backend, got {other:?}"),
5784        }
5785
5786        clear_search_env();
5787    }
5788
5789    #[test]
5790    fn search_backend_explicit_auto_is_supported() {
5791        let _guard = search_env_lock().lock().expect("env lock");
5792        clear_search_env();
5793        std::env::set_var("TANDEM_SEARCH_BACKEND", "auto");
5794        std::env::set_var("TANDEM_BRAVE_SEARCH_API_KEY", "brave-test-key");
5795        std::env::set_var("TANDEM_EXA_API_KEY", "exa-test-key");
5796
5797        let backend = SearchBackend::from_env();
5798
5799        match backend {
5800            SearchBackend::Auto { backends } => {
5801                assert_eq!(backends.len(), 2);
5802                assert!(matches!(backends[0], SearchBackend::Brave { .. }));
5803                assert!(matches!(backends[1], SearchBackend::Exa { .. }));
5804            }
5805            other => panic!("expected auto backend, got {other:?}"),
5806        }
5807
5808        clear_search_env();
5809    }
5810
5811    #[test]
5812    fn search_backend_implicit_auto_failover_when_multiple_backends_are_configured() {
5813        let _guard = search_env_lock().lock().expect("env lock");
5814        clear_search_env();
5815        std::env::set_var("TANDEM_BRAVE_SEARCH_API_KEY", "brave-test-key");
5816        std::env::set_var("TANDEM_EXA_API_KEY", "exa-test-key");
5817
5818        let backend = SearchBackend::from_env();
5819
5820        match backend {
5821            SearchBackend::Auto { backends } => {
5822                assert_eq!(backends.len(), 2);
5823                assert!(matches!(backends[0], SearchBackend::Brave { .. }));
5824                assert!(matches!(backends[1], SearchBackend::Exa { .. }));
5825            }
5826            other => panic!("expected auto backend, got {other:?}"),
5827        }
5828
5829        clear_search_env();
5830    }
5831
5832    #[test]
5833    fn search_backend_supports_legacy_exa_env_key() {
5834        let _guard = search_env_lock().lock().expect("env lock");
5835        clear_search_env();
5836        std::env::set_var("TANDEM_SEARCH_BACKEND", "exa");
5837        std::env::set_var("TANDEM_EXA_SEARCH_API_KEY", "legacy-exa-test-key");
5838
5839        let backend = SearchBackend::from_env();
5840
5841        match backend {
5842            SearchBackend::Exa { api_key, .. } => {
5843                assert_eq!(api_key, "legacy-exa-test-key");
5844            }
5845            other => panic!("expected exa backend, got {other:?}"),
5846        }
5847
5848        clear_search_env();
5849    }
5850
5851    #[test]
5852    fn normalize_brave_results_accepts_standard_web_payload_rows() {
5853        let raw = vec![json!({
5854            "title": "Agentic workflows",
5855            "url": "https://example.com/agentic",
5856            "description": "A practical overview of agentic workflows.",
5857            "profile": {
5858                "long_name": "example.com"
5859            }
5860        })];
5861
5862        let results = normalize_brave_results(&raw, 5);
5863
5864        assert_eq!(results.len(), 1);
5865        assert_eq!(results[0].title, "Agentic workflows");
5866        assert_eq!(results[0].url, "https://example.com/agentic");
5867        assert_eq!(
5868            results[0].snippet,
5869            "A practical overview of agentic workflows."
5870        );
5871        assert_eq!(results[0].source, "brave:example.com");
5872    }
5873
5874    #[test]
5875    fn search_backend_explicit_none_disables_websearch() {
5876        let _guard = search_env_lock().lock().expect("env lock");
5877        clear_search_env();
5878        std::env::set_var("TANDEM_SEARCH_BACKEND", "none");
5879        std::env::set_var("TANDEM_SEARXNG_URL", "http://localhost:8080");
5880
5881        let backend = SearchBackend::from_env();
5882
5883        assert!(matches!(backend, SearchBackend::Disabled { .. }));
5884
5885        clear_search_env();
5886    }
5887
5888    #[tokio::test]
5889    async fn tool_registry_includes_websearch_by_default() {
5890        let _guard = search_env_lock().lock().expect("env lock");
5891        clear_search_env();
5892
5893        let registry = ToolRegistry::new();
5894        let names = registry
5895            .list()
5896            .await
5897            .into_iter()
5898            .map(|schema| schema.name)
5899            .collect::<Vec<_>>();
5900
5901        assert!(names.iter().any(|name| name == "websearch"));
5902
5903        clear_search_env();
5904    }
5905
5906    #[tokio::test]
5907    async fn tool_registry_omits_websearch_when_search_backend_explicitly_disabled() {
5908        let _guard = search_env_lock().lock().expect("env lock");
5909        clear_search_env();
5910        std::env::set_var("TANDEM_SEARCH_BACKEND", "none");
5911
5912        let registry = ToolRegistry::new();
5913        let names = registry
5914            .list()
5915            .await
5916            .into_iter()
5917            .map(|schema| schema.name)
5918            .collect::<Vec<_>>();
5919
5920        assert!(!names.iter().any(|name| name == "websearch"));
5921
5922        clear_search_env();
5923    }
5924
5925    #[test]
5926    fn normalize_searxng_results_preserves_title_url_and_engine() {
5927        let results = normalize_searxng_results(
5928            &[json!({
5929                "title": "Tandem Docs",
5930                "url": "https://docs.tandem.ac/",
5931                "content": "Official documentation for Tandem.",
5932                "engine": "duckduckgo"
5933            })],
5934            8,
5935        );
5936
5937        assert_eq!(results.len(), 1);
5938        assert_eq!(results[0].title, "Tandem Docs");
5939        assert_eq!(results[0].url, "https://docs.tandem.ac/");
5940        assert_eq!(results[0].snippet, "Official documentation for Tandem.");
5941        assert_eq!(results[0].source, "searxng:duckduckgo");
5942    }
5943
5944    #[test]
5945    fn test_html_stripping_and_markdown_reduction() {
5946        let html = r#"
5947            <!DOCTYPE html>
5948            <html>
5949            <head>
5950                <title>Test Page</title>
5951                <style>
5952                    body { color: red; }
5953                </style>
5954                <script>
5955                    console.log("noisy script");
5956                </script>
5957            </head>
5958            <body>
5959                <h1>Hello World</h1>
5960                <p>This is a <a href="https://example.com">link</a>.</p>
5961                <noscript>Enable JS</noscript>
5962            </body>
5963            </html>
5964        "#;
5965
5966        let cleaned = strip_html_noise(html);
5967        assert!(!cleaned.contains("noisy script"));
5968        assert!(!cleaned.contains("color: red"));
5969        assert!(!cleaned.contains("Enable JS"));
5970        assert!(cleaned.contains("Hello World"));
5971
5972        let markdown = html2md::parse_html(&cleaned);
5973        let text = markdown_to_text(&markdown);
5974
5975        // Raw length includes all the noise
5976        let raw_len = html.len();
5977        // Markdown length should be significantly smaller
5978        let md_len = markdown.len();
5979
5980        println!("Raw: {}, Markdown: {}", raw_len, md_len);
5981        assert!(
5982            md_len < raw_len / 2,
5983            "Markdown should be < 50% of raw HTML size"
5984        );
5985        assert!(text.contains("Hello World"));
5986        assert!(text.contains("link"));
5987    }
5988
5989    #[test]
5990    fn memory_scope_defaults_to_hidden_context() {
5991        let args = json!({
5992            "__session_id": "session-123",
5993            "__project_id": "workspace-abc"
5994        });
5995        assert_eq!(memory_session_id(&args).as_deref(), Some("session-123"));
5996        assert_eq!(memory_project_id(&args).as_deref(), Some("workspace-abc"));
5997        assert!(global_memory_enabled(&args));
5998    }
5999
6000    #[test]
6001    fn memory_scope_policy_can_disable_global_visibility() {
6002        let args = json!({
6003            "__session_id": "session-123",
6004            "__project_id": "workspace-abc",
6005            "__memory_max_visible_scope": "project"
6006        });
6007        assert_eq!(memory_visible_scope(&args), MemoryVisibleScope::Project);
6008        assert!(!global_memory_enabled(&args));
6009    }
6010
6011    #[test]
6012    fn memory_db_path_ignores_public_db_path_arg() {
6013        std::env::set_var("TANDEM_MEMORY_DB_PATH", "/tmp/global-memory.sqlite");
6014        let resolved = resolve_memory_db_path(&json!({
6015            "db_path": "/home/user123/tandem"
6016        }));
6017        assert_eq!(resolved, PathBuf::from("/tmp/global-memory.sqlite"));
6018        std::env::remove_var("TANDEM_MEMORY_DB_PATH");
6019    }
6020
6021    #[test]
6022    fn memory_db_path_accepts_hidden_override() {
6023        std::env::set_var("TANDEM_MEMORY_DB_PATH", "/tmp/global-memory.sqlite");
6024        let resolved = resolve_memory_db_path(&json!({
6025            "__memory_db_path": "/tmp/internal-memory.sqlite",
6026            "db_path": "/home/user123/tandem"
6027        }));
6028        assert_eq!(resolved, PathBuf::from("/tmp/internal-memory.sqlite"));
6029        std::env::remove_var("TANDEM_MEMORY_DB_PATH");
6030    }
6031
6032    #[tokio::test]
6033    async fn memory_search_uses_global_by_default() {
6034        let tool = MemorySearchTool;
6035        let result = tool
6036            .execute(json!({
6037                "query": "global pattern",
6038                "tier": "global"
6039            }))
6040            .await
6041            .expect("memory_search should return ToolResult");
6042        assert!(
6043            result.output.contains("memory database not found")
6044                || result.output.contains("memory embeddings unavailable")
6045        );
6046        assert_eq!(result.metadata["ok"], json!(false));
6047        let reason = result
6048            .metadata
6049            .get("reason")
6050            .and_then(|v| v.as_str())
6051            .unwrap_or_default();
6052        assert!(matches!(
6053            reason,
6054            "memory_db_missing" | "embeddings_unavailable"
6055        ));
6056    }
6057
6058    #[tokio::test]
6059    async fn memory_store_uses_hidden_project_scope_by_default() {
6060        let tool = MemoryStoreTool;
6061        let result = tool
6062            .execute(json!({
6063                "content": "remember this",
6064                "__session_id": "session-123",
6065                "__project_id": "workspace-abc"
6066            }))
6067            .await
6068            .expect("memory_store should return ToolResult");
6069        assert!(
6070            result.output.contains("memory embeddings unavailable")
6071                || result.output.contains("memory database not found")
6072        );
6073        let reason = result
6074            .metadata
6075            .get("reason")
6076            .and_then(|v| v.as_str())
6077            .unwrap_or_default();
6078        assert!(matches!(
6079            reason,
6080            "embeddings_unavailable" | "memory_db_missing"
6081        ));
6082    }
6083
6084    #[tokio::test]
6085    async fn memory_delete_uses_hidden_project_scope_by_default() {
6086        let tool = MemoryDeleteTool;
6087        let result = tool
6088            .execute(json!({
6089                "chunk_id": "chunk-123",
6090                "__session_id": "session-123",
6091                "__project_id": "workspace-abc",
6092                "__memory_db_path": "/tmp/tandem-memory-delete-test.sqlite"
6093            }))
6094            .await
6095            .expect("memory_delete should return ToolResult");
6096        assert_eq!(result.metadata["tier"], json!("project"));
6097        assert_eq!(result.metadata["project_id"], json!("workspace-abc"));
6098        assert!(matches!(
6099            result
6100                .metadata
6101                .get("reason")
6102                .and_then(|v| v.as_str())
6103                .unwrap_or_default(),
6104            "not_found"
6105        ));
6106    }
6107
6108    #[test]
6109    fn translate_windows_ls_with_all_flag() {
6110        let translated = translate_windows_shell_command("ls -la").expect("translation");
6111        assert!(translated.contains("Get-ChildItem"));
6112        assert!(translated.contains("-Force"));
6113    }
6114
6115    #[test]
6116    fn translate_windows_find_name_pattern() {
6117        let translated =
6118            translate_windows_shell_command("find . -type f -name \"*.rs\"").expect("translation");
6119        assert!(translated.contains("Get-ChildItem"));
6120        assert!(translated.contains("-Recurse"));
6121        assert!(translated.contains("-Filter"));
6122    }
6123
6124    #[test]
6125    fn windows_guardrail_blocks_untranslatable_unix_command() {
6126        assert_eq!(
6127            windows_guardrail_reason("sed -n '1,5p' README.md"),
6128            Some("unix_command_untranslatable")
6129        );
6130    }
6131
6132    #[test]
6133    fn path_policy_rejects_tool_markup_and_globs() {
6134        assert!(resolve_tool_path(
6135            "<tool_call><function=glob><parameter=pattern>**/*</parameter></function></tool_call>",
6136            &json!({})
6137        )
6138        .is_none());
6139        assert!(resolve_tool_path("**/*", &json!({})).is_none());
6140        assert!(resolve_tool_path("/", &json!({})).is_none());
6141        assert!(resolve_tool_path("C:\\", &json!({})).is_none());
6142    }
6143
6144    #[tokio::test]
6145    async fn glob_allows_tandem_artifact_paths() {
6146        let root =
6147            std::env::temp_dir().join(format!("tandem-glob-artifacts-{}", uuid_like(now_ms_u64())));
6148        let artifacts_dir = root.join(".tandem").join("artifacts");
6149        std::fs::create_dir_all(&artifacts_dir).expect("create artifacts dir");
6150        let artifact = artifacts_dir.join("report.json");
6151        std::fs::write(&artifact, "{\"ok\":true}").expect("write artifact");
6152
6153        let tool = GlobTool;
6154        let result = tool
6155            .execute(json!({
6156                "pattern": ".tandem/artifacts/*.json",
6157                "__workspace_root": root.to_string_lossy().to_string(),
6158                "__effective_cwd": root.to_string_lossy().to_string(),
6159            }))
6160            .await
6161            .expect("glob result");
6162
6163        assert!(
6164            result.output.contains(".tandem/artifacts/report.json"),
6165            "expected artifact path in glob output, got: {}",
6166            result.output
6167        );
6168    }
6169
6170    #[tokio::test]
6171    async fn glob_still_hides_non_artifact_tandem_paths() {
6172        let root =
6173            std::env::temp_dir().join(format!("tandem-glob-hidden-{}", uuid_like(now_ms_u64())));
6174        let tandem_dir = root.join(".tandem");
6175        let artifacts_dir = tandem_dir.join("artifacts");
6176        std::fs::create_dir_all(&artifacts_dir).expect("create tandem dirs");
6177        std::fs::write(tandem_dir.join("secrets.json"), "{\"hidden\":true}")
6178            .expect("write hidden file");
6179
6180        let tool = GlobTool;
6181        let result = tool
6182            .execute(json!({
6183                "pattern": ".tandem/*.json",
6184                "__workspace_root": root.to_string_lossy().to_string(),
6185                "__effective_cwd": root.to_string_lossy().to_string(),
6186            }))
6187            .await
6188            .expect("glob result");
6189
6190        assert!(
6191            result.output.trim().is_empty(),
6192            "expected non-artifact tandem paths to stay hidden, got: {}",
6193            result.output
6194        );
6195    }
6196
6197    #[test]
6198    fn normalize_recursive_wildcard_pattern_fixes_common_invalid_forms() {
6199        assert_eq!(
6200            normalize_recursive_wildcard_pattern("docs/**.md").as_deref(),
6201            Some("docs/**/*.md")
6202        );
6203        assert_eq!(
6204            normalize_recursive_wildcard_pattern("src/**README*").as_deref(),
6205            Some("src/**/README*")
6206        );
6207        assert_eq!(
6208            normalize_recursive_wildcard_pattern("**.{md,mdx,txt}").as_deref(),
6209            Some("**/*.{md,mdx,txt}")
6210        );
6211        assert_eq!(normalize_recursive_wildcard_pattern("docs/**/*.md"), None);
6212    }
6213
6214    #[tokio::test]
6215    async fn glob_recovers_from_invalid_recursive_wildcard_syntax() {
6216        let root =
6217            std::env::temp_dir().join(format!("tandem-glob-recover-{}", uuid_like(now_ms_u64())));
6218        let docs_dir = root.join("docs").join("guides");
6219        std::fs::create_dir_all(&docs_dir).expect("create docs dir");
6220        let guide = docs_dir.join("intro.md");
6221        std::fs::write(&guide, "# intro").expect("write guide");
6222
6223        let tool = GlobTool;
6224        let result = tool
6225            .execute(json!({
6226                "pattern": "docs/**.md",
6227                "__workspace_root": root.to_string_lossy().to_string(),
6228                "__effective_cwd": root.to_string_lossy().to_string(),
6229            }))
6230            .await
6231            .expect("glob result");
6232
6233        assert!(
6234            result.output.contains("docs/guides/intro.md"),
6235            "expected recovered glob output, got: {}",
6236            result.output
6237        );
6238        assert_eq!(
6239            result.metadata["effective_pattern"],
6240            json!(format!("{}/docs/**/*.md", root.to_string_lossy()))
6241        );
6242    }
6243
6244    #[cfg(windows)]
6245    #[test]
6246    fn path_policy_allows_windows_verbatim_paths_within_workspace() {
6247        let args = json!({
6248            "__workspace_root": r"C:\tandem-examples",
6249            "__effective_cwd": r"C:\tandem-examples\docs"
6250        });
6251        assert!(resolve_tool_path(r"\\?\C:\tandem-examples\docs\index.html", &args).is_some());
6252    }
6253
6254    #[cfg(not(windows))]
6255    #[test]
6256    fn path_policy_allows_absolute_linux_paths_within_workspace() {
6257        let args = json!({
6258            "__workspace_root": "/tmp/tandem-examples",
6259            "__effective_cwd": "/tmp/tandem-examples/docs"
6260        });
6261        assert!(resolve_tool_path("/tmp/tandem-examples/docs/index.html", &args).is_some());
6262        assert!(resolve_tool_path("/etc/passwd", &args).is_none());
6263    }
6264
6265    #[test]
6266    fn read_fallback_resolves_unique_suffix_filename() {
6267        let root =
6268            std::env::temp_dir().join(format!("tandem-read-fallback-{}", uuid_like(now_ms_u64())));
6269        std::fs::create_dir_all(&root).expect("create root");
6270        let target = root.join("T1011U kitöltési útmutató.pdf");
6271        std::fs::write(&target, b"stub").expect("write test file");
6272
6273        let args = json!({
6274            "__workspace_root": root.to_string_lossy().to_string(),
6275            "__effective_cwd": root.to_string_lossy().to_string()
6276        });
6277        let resolved = resolve_read_path_fallback("útmutató.pdf", &args)
6278            .expect("expected unique suffix match");
6279        assert_eq!(resolved, target);
6280
6281        let _ = std::fs::remove_dir_all(&root);
6282    }
6283
6284    #[tokio::test]
6285    async fn write_tool_rejects_empty_content_by_default() {
6286        let tool = WriteTool;
6287        let result = tool
6288            .execute(json!({
6289                "path":"target/write_guard_test.txt",
6290                "content":""
6291            }))
6292            .await
6293            .expect("write tool should return ToolResult");
6294        assert!(result.output.contains("non-empty `content`"));
6295        assert_eq!(result.metadata["reason"], json!("empty_content"));
6296        assert!(!Path::new("target/write_guard_test.txt").exists());
6297    }
6298
6299    #[tokio::test]
6300    async fn registry_resolves_default_api_namespaced_tool() {
6301        let registry = ToolRegistry::new();
6302        let result = registry
6303            .execute("default_api:read", json!({"path":"Cargo.toml"}))
6304            .await
6305            .expect("registry execute should return ToolResult");
6306        assert!(!result.output.starts_with("Unknown tool:"));
6307    }
6308
6309    #[tokio::test]
6310    async fn batch_resolves_default_api_namespaced_tool() {
6311        let tool = BatchTool;
6312        let result = tool
6313            .execute(json!({
6314                "tool_calls":[
6315                    {"tool":"default_api:read","args":{"path":"Cargo.toml"}}
6316                ]
6317            }))
6318            .await
6319            .expect("batch should return ToolResult");
6320        assert!(!result.output.contains("Unknown tool: default_api:read"));
6321    }
6322
6323    #[tokio::test]
6324    async fn batch_prefers_name_when_tool_is_default_api_wrapper() {
6325        let tool = BatchTool;
6326        let result = tool
6327            .execute(json!({
6328                "tool_calls":[
6329                    {"tool":"default_api","name":"read","args":{"path":"Cargo.toml"}}
6330                ]
6331            }))
6332            .await
6333            .expect("batch should return ToolResult");
6334        assert!(!result.output.contains("Unknown tool: default_api"));
6335    }
6336
6337    #[tokio::test]
6338    async fn batch_resolves_nested_function_name_for_wrapper_tool() {
6339        let tool = BatchTool;
6340        let result = tool
6341            .execute(json!({
6342                "tool_calls":[
6343                    {
6344                        "tool":"default_api",
6345                        "function":{"name":"read"},
6346                        "args":{"path":"Cargo.toml"}
6347                    }
6348                ]
6349            }))
6350            .await
6351            .expect("batch should return ToolResult");
6352        assert!(!result.output.contains("Unknown tool: default_api"));
6353    }
6354
6355    #[tokio::test]
6356    async fn batch_drops_wrapper_calls_without_resolvable_name() {
6357        let tool = BatchTool;
6358        let result = tool
6359            .execute(json!({
6360                "tool_calls":[
6361                    {"tool":"default_api","args":{"path":"Cargo.toml"}}
6362                ]
6363            }))
6364            .await
6365            .expect("batch should return ToolResult");
6366        assert_eq!(result.metadata["count"], json!(0));
6367    }
6368
6369    #[test]
6370    fn sanitize_member_name_normalizes_agent_aliases() {
6371        assert_eq!(sanitize_member_name("A2").expect("valid"), "A2");
6372        assert_eq!(sanitize_member_name("a7").expect("valid"), "A7");
6373        assert_eq!(
6374            sanitize_member_name("  qa reviewer ").expect("valid"),
6375            "qa-reviewer"
6376        );
6377        assert!(sanitize_member_name("   ").is_err());
6378    }
6379
6380    #[tokio::test]
6381    async fn next_default_member_name_skips_existing_indices() {
6382        let root = std::env::temp_dir().join(format!(
6383            "tandem-agent-team-test-{}",
6384            uuid_like(now_ms_u64())
6385        ));
6386        let paths = AgentTeamPaths::new(root.join(".tandem"));
6387        let team_name = "alpha";
6388        fs::create_dir_all(paths.team_dir(team_name))
6389            .await
6390            .expect("create team dir");
6391        write_json_file(
6392            paths.members_file(team_name),
6393            &json!([
6394                {"name":"A1"},
6395                {"name":"A2"},
6396                {"name":"agent-x"},
6397                {"name":"A5"}
6398            ]),
6399        )
6400        .await
6401        .expect("write members");
6402
6403        let next = next_default_member_name(&paths, team_name)
6404            .await
6405            .expect("next member");
6406        assert_eq!(next, "A6");
6407
6408        let _ =
6409            fs::remove_dir_all(PathBuf::from(paths.root().parent().unwrap_or(paths.root()))).await;
6410    }
6411}
6412
6413async fn find_symbol_references(symbol: &str, root: &Path) -> String {
6414    if symbol.trim().is_empty() {
6415        return "missing symbol".to_string();
6416    }
6417    let escaped = regex::escape(symbol);
6418    let re = Regex::new(&format!(r"\b{}\b", escaped));
6419    let Ok(re) = re else {
6420        return "invalid symbol".to_string();
6421    };
6422    let mut refs = Vec::new();
6423    for entry in WalkBuilder::new(root).build().flatten() {
6424        if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
6425            continue;
6426        }
6427        let path = entry.path();
6428        if let Ok(content) = fs::read_to_string(path).await {
6429            for (idx, line) in content.lines().enumerate() {
6430                if re.is_match(line) {
6431                    refs.push(format!("{}:{}:{}", path.display(), idx + 1, line.trim()));
6432                    if refs.len() >= 200 {
6433                        return refs.join("\n");
6434                    }
6435                }
6436            }
6437        }
6438    }
6439    refs.join("\n")
6440}