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::sync::atomic::{AtomicU64, Ordering};
5use std::sync::Arc;
6
7use async_trait::async_trait;
8use ignore::WalkBuilder;
9use regex::Regex;
10use serde_json::{json, Value};
11use tandem_skills::SkillService;
12use tokio::fs;
13use tokio::process::Command;
14use tokio::sync::RwLock;
15use tokio_util::sync::CancellationToken;
16
17use futures_util::StreamExt;
18use tandem_memory::types::{MemorySearchResult, MemoryTier};
19use tandem_memory::MemoryManager;
20use tandem_types::{ToolResult, ToolSchema};
21
22#[async_trait]
23pub trait Tool: Send + Sync {
24    fn schema(&self) -> ToolSchema;
25    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult>;
26    async fn execute_with_cancel(
27        &self,
28        args: Value,
29        _cancel: CancellationToken,
30    ) -> anyhow::Result<ToolResult> {
31        self.execute(args).await
32    }
33}
34
35#[derive(Clone)]
36pub struct ToolRegistry {
37    tools: Arc<RwLock<HashMap<String, Arc<dyn Tool>>>>,
38}
39
40impl ToolRegistry {
41    pub fn new() -> Self {
42        let mut map: HashMap<String, Arc<dyn Tool>> = HashMap::new();
43        map.insert("bash".to_string(), Arc::new(BashTool));
44        map.insert("read".to_string(), Arc::new(ReadTool));
45        map.insert("write".to_string(), Arc::new(WriteTool));
46        map.insert("edit".to_string(), Arc::new(EditTool));
47        map.insert("glob".to_string(), Arc::new(GlobTool));
48        map.insert("grep".to_string(), Arc::new(GrepTool));
49        map.insert("webfetch".to_string(), Arc::new(WebFetchTool));
50        map.insert(
51            "webfetch_document".to_string(),
52            Arc::new(WebFetchDocumentTool),
53        );
54        map.insert("mcp_debug".to_string(), Arc::new(McpDebugTool));
55        map.insert("websearch".to_string(), Arc::new(WebSearchTool));
56        map.insert("codesearch".to_string(), Arc::new(CodeSearchTool));
57        let todo_tool: Arc<dyn Tool> = Arc::new(TodoWriteTool);
58        map.insert("todo_write".to_string(), todo_tool.clone());
59        map.insert("todowrite".to_string(), todo_tool.clone());
60        map.insert("update_todo_list".to_string(), todo_tool);
61        map.insert("task".to_string(), Arc::new(TaskTool));
62        map.insert("question".to_string(), Arc::new(QuestionTool));
63        map.insert("spawn_agent".to_string(), Arc::new(SpawnAgentTool));
64        map.insert("skill".to_string(), Arc::new(SkillTool));
65        map.insert("memory_search".to_string(), Arc::new(MemorySearchTool));
66        map.insert("apply_patch".to_string(), Arc::new(ApplyPatchTool));
67        map.insert("batch".to_string(), Arc::new(BatchTool));
68        map.insert("lsp".to_string(), Arc::new(LspTool));
69        Self {
70            tools: Arc::new(RwLock::new(map)),
71        }
72    }
73
74    pub async fn list(&self) -> Vec<ToolSchema> {
75        let mut dedup: HashMap<String, ToolSchema> = HashMap::new();
76        for schema in self.tools.read().await.values().map(|t| t.schema()) {
77            dedup.entry(schema.name.clone()).or_insert(schema);
78        }
79        let mut schemas = dedup.into_values().collect::<Vec<_>>();
80        schemas.sort_by(|a, b| a.name.cmp(&b.name));
81        schemas
82    }
83
84    pub async fn execute(&self, name: &str, args: Value) -> anyhow::Result<ToolResult> {
85        let tools = self.tools.read().await;
86        let Some(tool) = tools.get(name) else {
87            return Ok(ToolResult {
88                output: format!("Unknown tool: {name}"),
89                metadata: json!({}),
90            });
91        };
92        tool.execute(args).await
93    }
94
95    pub async fn execute_with_cancel(
96        &self,
97        name: &str,
98        args: Value,
99        cancel: CancellationToken,
100    ) -> anyhow::Result<ToolResult> {
101        let tools = self.tools.read().await;
102        let Some(tool) = tools.get(name) else {
103            return Ok(ToolResult {
104                output: format!("Unknown tool: {name}"),
105                metadata: json!({}),
106            });
107        };
108        tool.execute_with_cancel(args, cancel).await
109    }
110}
111
112impl Default for ToolRegistry {
113    fn default() -> Self {
114        Self::new()
115    }
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct ToolSchemaValidationError {
120    pub tool_name: String,
121    pub path: String,
122    pub reason: String,
123}
124
125impl std::fmt::Display for ToolSchemaValidationError {
126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        write!(
128            f,
129            "invalid tool schema `{}` at `{}`: {}",
130            self.tool_name, self.path, self.reason
131        )
132    }
133}
134
135impl std::error::Error for ToolSchemaValidationError {}
136
137pub fn validate_tool_schemas(schemas: &[ToolSchema]) -> Result<(), ToolSchemaValidationError> {
138    for schema in schemas {
139        validate_schema_node(&schema.name, "$", &schema.input_schema)?;
140    }
141    Ok(())
142}
143
144fn validate_schema_node(
145    tool_name: &str,
146    path: &str,
147    value: &Value,
148) -> Result<(), ToolSchemaValidationError> {
149    let Some(obj) = value.as_object() else {
150        if let Some(arr) = value.as_array() {
151            for (idx, item) in arr.iter().enumerate() {
152                validate_schema_node(tool_name, &format!("{path}[{idx}]"), item)?;
153            }
154        }
155        return Ok(());
156    };
157
158    if obj.get("type").and_then(|t| t.as_str()) == Some("array") && !obj.contains_key("items") {
159        return Err(ToolSchemaValidationError {
160            tool_name: tool_name.to_string(),
161            path: path.to_string(),
162            reason: "array schema missing items".to_string(),
163        });
164    }
165
166    if let Some(items) = obj.get("items") {
167        validate_schema_node(tool_name, &format!("{path}.items"), items)?;
168    }
169    if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
170        for (key, child) in props {
171            validate_schema_node(tool_name, &format!("{path}.properties.{key}"), child)?;
172        }
173    }
174    if let Some(additional_props) = obj.get("additionalProperties") {
175        validate_schema_node(
176            tool_name,
177            &format!("{path}.additionalProperties"),
178            additional_props,
179        )?;
180    }
181    if let Some(one_of) = obj.get("oneOf").and_then(|v| v.as_array()) {
182        for (idx, child) in one_of.iter().enumerate() {
183            validate_schema_node(tool_name, &format!("{path}.oneOf[{idx}]"), child)?;
184        }
185    }
186    if let Some(any_of) = obj.get("anyOf").and_then(|v| v.as_array()) {
187        for (idx, child) in any_of.iter().enumerate() {
188            validate_schema_node(tool_name, &format!("{path}.anyOf[{idx}]"), child)?;
189        }
190    }
191    if let Some(all_of) = obj.get("allOf").and_then(|v| v.as_array()) {
192        for (idx, child) in all_of.iter().enumerate() {
193            validate_schema_node(tool_name, &format!("{path}.allOf[{idx}]"), child)?;
194        }
195    }
196
197    Ok(())
198}
199
200fn is_path_allowed(path: &str) -> bool {
201    let raw = Path::new(path);
202    if raw.is_absolute() {
203        return false;
204    }
205    !raw.components()
206        .any(|c| matches!(c, std::path::Component::ParentDir))
207}
208
209struct BashTool;
210#[async_trait]
211impl Tool for BashTool {
212    fn schema(&self) -> ToolSchema {
213        ToolSchema {
214            name: "bash".to_string(),
215            description: "Run shell command".to_string(),
216            input_schema: json!({"type":"object","properties":{"command":{"type":"string"}}}),
217        }
218    }
219    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
220        let cmd = args["command"].as_str().unwrap_or("");
221        let mut command = Command::new("powershell");
222        command.args(["-NoProfile", "-Command", cmd]);
223        if let Some(env) = args.get("env").and_then(|v| v.as_object()) {
224            for (k, v) in env {
225                if let Some(value) = v.as_str() {
226                    command.env(k, value);
227                }
228            }
229        }
230        let output = command.output().await?;
231        Ok(ToolResult {
232            output: String::from_utf8_lossy(&output.stdout).to_string(),
233            metadata: json!({"stderr": String::from_utf8_lossy(&output.stderr)}),
234        })
235    }
236
237    async fn execute_with_cancel(
238        &self,
239        args: Value,
240        cancel: CancellationToken,
241    ) -> anyhow::Result<ToolResult> {
242        let cmd = args["command"].as_str().unwrap_or("");
243        let mut command = Command::new("powershell");
244        command.args(["-NoProfile", "-Command", cmd]);
245        if let Some(env) = args.get("env").and_then(|v| v.as_object()) {
246            for (k, v) in env {
247                if let Some(value) = v.as_str() {
248                    command.env(k, value);
249                }
250            }
251        }
252        let mut child = command.spawn()?;
253        let status = tokio::select! {
254            _ = cancel.cancelled() => {
255                let _ = child.kill().await;
256                return Ok(ToolResult {
257                    output: "command cancelled".to_string(),
258                    metadata: json!({"cancelled": true}),
259                });
260            }
261            result = child.wait() => result?
262        };
263        Ok(ToolResult {
264            output: format!("command exited: {}", status),
265            metadata: json!({}),
266        })
267    }
268}
269
270struct ReadTool;
271#[async_trait]
272impl Tool for ReadTool {
273    fn schema(&self) -> ToolSchema {
274        ToolSchema {
275            name: "read".to_string(),
276            description: "Read file contents".to_string(),
277            input_schema: json!({"type":"object","properties":{"path":{"type":"string"}}}),
278        }
279    }
280    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
281        let path = args["path"].as_str().unwrap_or("");
282        if !is_path_allowed(path) {
283            return Ok(ToolResult {
284                output: "path denied by sandbox policy".to_string(),
285                metadata: json!({"path": path}),
286            });
287        }
288        let data = fs::read_to_string(path).await.unwrap_or_default();
289        Ok(ToolResult {
290            output: data,
291            metadata: json!({}),
292        })
293    }
294}
295
296struct WriteTool;
297#[async_trait]
298impl Tool for WriteTool {
299    fn schema(&self) -> ToolSchema {
300        ToolSchema {
301            name: "write".to_string(),
302            description: "Write file contents".to_string(),
303            input_schema: json!({"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}}}),
304        }
305    }
306    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
307        let path = args["path"].as_str().unwrap_or("");
308        let content = args["content"].as_str().unwrap_or("");
309        if !is_path_allowed(path) {
310            return Ok(ToolResult {
311                output: "path denied by sandbox policy".to_string(),
312                metadata: json!({"path": path}),
313            });
314        }
315        fs::write(path, content).await?;
316        Ok(ToolResult {
317            output: "ok".to_string(),
318            metadata: json!({}),
319        })
320    }
321}
322
323struct EditTool;
324#[async_trait]
325impl Tool for EditTool {
326    fn schema(&self) -> ToolSchema {
327        ToolSchema {
328            name: "edit".to_string(),
329            description: "String replacement edit".to_string(),
330            input_schema: json!({"type":"object","properties":{"path":{"type":"string"},"old":{"type":"string"},"new":{"type":"string"}}}),
331        }
332    }
333    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
334        let path = args["path"].as_str().unwrap_or("");
335        let old = args["old"].as_str().unwrap_or("");
336        let new = args["new"].as_str().unwrap_or("");
337        if !is_path_allowed(path) {
338            return Ok(ToolResult {
339                output: "path denied by sandbox policy".to_string(),
340                metadata: json!({"path": path}),
341            });
342        }
343        let content = fs::read_to_string(path).await.unwrap_or_default();
344        let updated = content.replace(old, new);
345        fs::write(path, updated).await?;
346        Ok(ToolResult {
347            output: "ok".to_string(),
348            metadata: json!({}),
349        })
350    }
351}
352
353struct GlobTool;
354#[async_trait]
355impl Tool for GlobTool {
356    fn schema(&self) -> ToolSchema {
357        ToolSchema {
358            name: "glob".to_string(),
359            description: "Find files by glob".to_string(),
360            input_schema: json!({"type":"object","properties":{"pattern":{"type":"string"}}}),
361        }
362    }
363    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
364        let pattern = args["pattern"].as_str().unwrap_or("*");
365        if pattern.contains("..") {
366            return Ok(ToolResult {
367                output: "pattern denied by sandbox policy".to_string(),
368                metadata: json!({"pattern": pattern}),
369            });
370        }
371        let mut files = Vec::new();
372        for path in (glob::glob(pattern)?).flatten() {
373            files.push(path.display().to_string());
374            if files.len() >= 100 {
375                break;
376            }
377        }
378        Ok(ToolResult {
379            output: files.join("\n"),
380            metadata: json!({"count": files.len()}),
381        })
382    }
383}
384
385struct GrepTool;
386#[async_trait]
387impl Tool for GrepTool {
388    fn schema(&self) -> ToolSchema {
389        ToolSchema {
390            name: "grep".to_string(),
391            description: "Regex search in files".to_string(),
392            input_schema: json!({"type":"object","properties":{"pattern":{"type":"string"},"path":{"type":"string"}}}),
393        }
394    }
395    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
396        let pattern = args["pattern"].as_str().unwrap_or("");
397        let root = args["path"].as_str().unwrap_or(".");
398        if !is_path_allowed(root) {
399            return Ok(ToolResult {
400                output: "path denied by sandbox policy".to_string(),
401                metadata: json!({"path": root}),
402            });
403        }
404        let regex = Regex::new(pattern)?;
405        let mut out = Vec::new();
406        for entry in WalkBuilder::new(root).build().flatten() {
407            if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
408                continue;
409            }
410            let path = entry.path();
411            if let Ok(content) = fs::read_to_string(path).await {
412                for (idx, line) in content.lines().enumerate() {
413                    if regex.is_match(line) {
414                        out.push(format!("{}:{}:{}", path.display(), idx + 1, line));
415                        if out.len() >= 100 {
416                            break;
417                        }
418                    }
419                }
420            }
421            if out.len() >= 100 {
422                break;
423            }
424        }
425        Ok(ToolResult {
426            output: out.join("\n"),
427            metadata: json!({"count": out.len()}),
428        })
429    }
430}
431
432struct WebFetchTool;
433#[async_trait]
434impl Tool for WebFetchTool {
435    fn schema(&self) -> ToolSchema {
436        ToolSchema {
437            name: "webfetch".to_string(),
438            description: "Fetch URL text".to_string(),
439            input_schema: json!({"type":"object","properties":{"url":{"type":"string"}}}),
440        }
441    }
442    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
443        let url = args["url"].as_str().unwrap_or("");
444        let body = reqwest::get(url).await?.text().await?;
445        Ok(ToolResult {
446            output: body.chars().take(20_000).collect(),
447            metadata: json!({"truncated": body.len() > 20_000}),
448        })
449    }
450}
451
452struct WebFetchDocumentTool;
453#[async_trait]
454impl Tool for WebFetchDocumentTool {
455    fn schema(&self) -> ToolSchema {
456        ToolSchema {
457            name: "webfetch_document".to_string(),
458            description: "Fetch URL content and return a structured markdown document".to_string(),
459            input_schema: json!({
460                "type":"object",
461                "properties":{
462                    "url":{"type":"string"},
463                    "mode":{"type":"string"},
464                    "return":{"type":"string"},
465                    "max_bytes":{"type":"integer"},
466                    "timeout_ms":{"type":"integer"},
467                    "max_redirects":{"type":"integer"}
468                }
469            }),
470        }
471    }
472    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
473        let url = args["url"].as_str().unwrap_or("").trim();
474        if url.is_empty() {
475            return Ok(ToolResult {
476                output: "url is required".to_string(),
477                metadata: json!({"url": url}),
478            });
479        }
480        let mode = args["mode"].as_str().unwrap_or("auto");
481        let return_mode = args["return"].as_str().unwrap_or("both");
482        let timeout_ms = args["timeout_ms"]
483            .as_u64()
484            .unwrap_or(15_000)
485            .clamp(1_000, 120_000);
486        let max_bytes = args["max_bytes"].as_u64().unwrap_or(500_000).min(5_000_000) as usize;
487        let max_redirects = args["max_redirects"].as_u64().unwrap_or(5).min(20) as usize;
488
489        let client = reqwest::Client::builder()
490            .timeout(std::time::Duration::from_millis(timeout_ms))
491            .redirect(reqwest::redirect::Policy::limited(max_redirects))
492            .build()?;
493
494        let started = std::time::Instant::now();
495        let res = client
496            .get(url)
497            .header(
498                "Accept",
499                "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
500            )
501            .send()
502            .await?;
503        let final_url = res.url().to_string();
504        let content_type = res
505            .headers()
506            .get("content-type")
507            .and_then(|v| v.to_str().ok())
508            .unwrap_or("")
509            .to_string();
510
511        let mut stream = res.bytes_stream();
512        let mut buffer: Vec<u8> = Vec::new();
513        let mut truncated = false;
514        while let Some(chunk) = stream.next().await {
515            let chunk = chunk?;
516            if buffer.len() + chunk.len() > max_bytes {
517                let remaining = max_bytes.saturating_sub(buffer.len());
518                buffer.extend_from_slice(&chunk[..remaining]);
519                truncated = true;
520                break;
521            }
522            buffer.extend_from_slice(&chunk);
523        }
524        let raw = String::from_utf8_lossy(&buffer).to_string();
525
526        let cleaned = strip_html_noise(&raw);
527        let title = extract_title(&cleaned).unwrap_or_default();
528        let canonical = extract_canonical(&cleaned);
529        let links = extract_links(&cleaned);
530
531        let markdown = if content_type.contains("html") || content_type.is_empty() {
532            html2md::parse_html(&cleaned)
533        } else {
534            cleaned.clone()
535        };
536
537        let text = markdown_to_text(&markdown);
538
539        let markdown_out = if return_mode == "text" {
540            String::new()
541        } else {
542            markdown
543        };
544        let text_out = if return_mode == "markdown" {
545            String::new()
546        } else {
547            text
548        };
549
550        let raw_chars = raw.chars().count();
551        let markdown_chars = markdown_out.chars().count();
552        let reduction_pct = if raw_chars == 0 {
553            0.0
554        } else {
555            ((raw_chars.saturating_sub(markdown_chars)) as f64 / raw_chars as f64) * 100.0
556        };
557
558        let output = json!({
559            "url": url,
560            "final_url": final_url,
561            "title": title,
562            "content_type": content_type,
563            "markdown": markdown_out,
564            "text": text_out,
565            "links": links,
566            "meta": {
567                "canonical": canonical,
568                "mode": mode
569            },
570            "stats": {
571                "bytes_in": buffer.len(),
572                "bytes_out": markdown_chars,
573                "raw_chars": raw_chars,
574                "markdown_chars": markdown_chars,
575                "reduction_pct": reduction_pct,
576                "elapsed_ms": started.elapsed().as_millis(),
577                "truncated": truncated
578            }
579        });
580
581        Ok(ToolResult {
582            output: serde_json::to_string_pretty(&output)?,
583            metadata: json!({
584                "url": url,
585                "final_url": final_url,
586                "content_type": content_type,
587                "truncated": truncated
588            }),
589        })
590    }
591}
592
593fn strip_html_noise(input: &str) -> String {
594    let script_re = Regex::new(r"(?is)<script[^>]*>.*?</script>").unwrap();
595    let style_re = Regex::new(r"(?is)<style[^>]*>.*?</style>").unwrap();
596    let noscript_re = Regex::new(r"(?is)<noscript[^>]*>.*?</noscript>").unwrap();
597    let cleaned = script_re.replace_all(input, "");
598    let cleaned = style_re.replace_all(&cleaned, "");
599    let cleaned = noscript_re.replace_all(&cleaned, "");
600    cleaned.to_string()
601}
602
603fn extract_title(input: &str) -> Option<String> {
604    let title_re = Regex::new(r"(?is)<title[^>]*>(.*?)</title>").ok()?;
605    let caps = title_re.captures(input)?;
606    let raw = caps.get(1)?.as_str();
607    let tag_re = Regex::new(r"(?is)<[^>]+>").ok()?;
608    Some(tag_re.replace_all(raw, "").trim().to_string())
609}
610
611fn extract_canonical(input: &str) -> Option<String> {
612    let canon_re =
613        Regex::new(r#"(?is)<link[^>]*rel=["']canonical["'][^>]*href=["']([^"']+)["'][^>]*>"#)
614            .ok()?;
615    let caps = canon_re.captures(input)?;
616    Some(caps.get(1)?.as_str().trim().to_string())
617}
618
619fn extract_links(input: &str) -> Vec<Value> {
620    let link_re = Regex::new(r#"(?is)<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>"#).unwrap();
621    let tag_re = Regex::new(r"(?is)<[^>]+>").unwrap();
622    let mut out = Vec::new();
623    for caps in link_re.captures_iter(input).take(200) {
624        let href = caps.get(1).map(|m| m.as_str()).unwrap_or("").trim();
625        let raw_text = caps.get(2).map(|m| m.as_str()).unwrap_or("");
626        let text = tag_re.replace_all(raw_text, "");
627        if !href.is_empty() {
628            out.push(json!({
629                "text": text.trim(),
630                "href": href
631            }));
632        }
633    }
634    out
635}
636
637fn markdown_to_text(input: &str) -> String {
638    let code_block_re = Regex::new(r"(?s)```.*?```").unwrap();
639    let inline_code_re = Regex::new(r"`[^`]*`").unwrap();
640    let link_re = Regex::new(r"\[([^\]]+)\]\([^)]+\)").unwrap();
641    let emphasis_re = Regex::new(r"[*_~]+").unwrap();
642    let cleaned = code_block_re.replace_all(input, "");
643    let cleaned = inline_code_re.replace_all(&cleaned, "");
644    let cleaned = link_re.replace_all(&cleaned, "$1");
645    let cleaned = emphasis_re.replace_all(&cleaned, "");
646    let cleaned = cleaned.replace('#', "");
647    let whitespace_re = Regex::new(r"\n{3,}").unwrap();
648    let cleaned = whitespace_re.replace_all(&cleaned, "\n\n");
649    cleaned.trim().to_string()
650}
651
652struct McpDebugTool;
653#[async_trait]
654impl Tool for McpDebugTool {
655    fn schema(&self) -> ToolSchema {
656        ToolSchema {
657            name: "mcp_debug".to_string(),
658            description: "Call an MCP tool and return the raw response".to_string(),
659            input_schema: json!({
660                "type":"object",
661                "properties":{
662                    "url":{"type":"string"},
663                    "tool":{"type":"string"},
664                    "args":{"type":"object"},
665                    "headers":{"type":"object"},
666                    "timeout_ms":{"type":"integer"},
667                    "max_bytes":{"type":"integer"}
668                }
669            }),
670        }
671    }
672    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
673        let url = args["url"].as_str().unwrap_or("").trim();
674        let tool = args["tool"].as_str().unwrap_or("").trim();
675        if url.is_empty() || tool.is_empty() {
676            return Ok(ToolResult {
677                output: "url and tool are required".to_string(),
678                metadata: json!({"url": url, "tool": tool}),
679            });
680        }
681        let timeout_ms = args["timeout_ms"]
682            .as_u64()
683            .unwrap_or(15_000)
684            .clamp(1_000, 120_000);
685        let max_bytes = args["max_bytes"].as_u64().unwrap_or(200_000).min(5_000_000) as usize;
686        let request_args = args.get("args").cloned().unwrap_or_else(|| json!({}));
687
688        #[derive(serde::Serialize)]
689        struct McpCallRequest {
690            jsonrpc: String,
691            id: u32,
692            method: String,
693            params: McpCallParams,
694        }
695
696        #[derive(serde::Serialize)]
697        struct McpCallParams {
698            name: String,
699            arguments: Value,
700        }
701
702        let request = McpCallRequest {
703            jsonrpc: "2.0".to_string(),
704            id: 1,
705            method: "tools/call".to_string(),
706            params: McpCallParams {
707                name: tool.to_string(),
708                arguments: request_args,
709            },
710        };
711
712        let client = reqwest::Client::builder()
713            .timeout(std::time::Duration::from_millis(timeout_ms))
714            .build()?;
715
716        let mut builder = client
717            .post(url)
718            .header("Content-Type", "application/json")
719            .header("Accept", "application/json, text/event-stream");
720
721        if let Some(headers) = args.get("headers").and_then(|v| v.as_object()) {
722            for (key, value) in headers {
723                if let Some(value) = value.as_str() {
724                    builder = builder.header(key, value);
725                }
726            }
727        }
728
729        let res = builder.json(&request).send().await?;
730        let status = res.status().as_u16();
731
732        let mut response_headers = serde_json::Map::new();
733        for (key, value) in res.headers().iter() {
734            if let Ok(value) = value.to_str() {
735                response_headers.insert(key.to_string(), Value::String(value.to_string()));
736            }
737        }
738
739        let mut stream = res.bytes_stream();
740        let mut buffer: Vec<u8> = Vec::new();
741        let mut truncated = false;
742
743        while let Some(chunk) = stream.next().await {
744            let chunk = chunk?;
745            if buffer.len() + chunk.len() > max_bytes {
746                let remaining = max_bytes.saturating_sub(buffer.len());
747                buffer.extend_from_slice(&chunk[..remaining]);
748                truncated = true;
749                break;
750            }
751            buffer.extend_from_slice(&chunk);
752        }
753
754        let body = String::from_utf8_lossy(&buffer).to_string();
755        let output = json!({
756            "status": status,
757            "headers": response_headers,
758            "body": body,
759            "truncated": truncated,
760            "bytes": buffer.len()
761        });
762
763        Ok(ToolResult {
764            output: serde_json::to_string_pretty(&output)?,
765            metadata: json!({
766                "url": url,
767                "tool": tool,
768                "timeout_ms": timeout_ms,
769                "max_bytes": max_bytes
770            }),
771        })
772    }
773}
774
775struct WebSearchTool;
776#[async_trait]
777impl Tool for WebSearchTool {
778    fn schema(&self) -> ToolSchema {
779        ToolSchema {
780            name: "websearch".to_string(),
781            description: "Search web results using Exa.ai MCP endpoint".to_string(),
782            input_schema: json!({
783                "type": "object",
784                "properties": {
785                    "query": { "type": "string" },
786                    "limit": { "type": "integer" }
787                },
788                "required": ["query"]
789            }),
790        }
791    }
792    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
793        let query = extract_websearch_query(&args).unwrap_or_default();
794        let query_source = args
795            .get("__query_source")
796            .and_then(|v| v.as_str())
797            .map(|s| s.to_string())
798            .unwrap_or_else(|| {
799                if query.is_empty() {
800                    "missing".to_string()
801                } else {
802                    "tool_args".to_string()
803                }
804            });
805        let query_hash = if query.is_empty() {
806            None
807        } else {
808            Some(stable_hash(&query))
809        };
810        if query.is_empty() {
811            tracing::warn!("WebSearchTool missing query. Args: {}", args);
812            return Ok(ToolResult {
813                output: format!("missing query. Received args: {}", args),
814                metadata: json!({
815                    "count": 0,
816                    "error": "missing_query",
817                    "query_source": query_source,
818                    "query_hash": query_hash,
819                    "loop_guard_triggered": false
820                }),
821            });
822        }
823        let num_results = extract_websearch_limit(&args).unwrap_or(8);
824
825        #[derive(serde::Serialize)]
826        struct McpSearchRequest {
827            jsonrpc: String,
828            id: u32,
829            method: String,
830            params: McpSearchParams,
831        }
832
833        #[derive(serde::Serialize)]
834        struct McpSearchParams {
835            name: String,
836            arguments: McpSearchArgs,
837        }
838
839        #[derive(serde::Serialize)]
840        struct McpSearchArgs {
841            query: String,
842            #[serde(rename = "numResults")]
843            num_results: u64,
844        }
845
846        let request = McpSearchRequest {
847            jsonrpc: "2.0".to_string(),
848            id: 1,
849            method: "tools/call".to_string(),
850            params: McpSearchParams {
851                name: "web_search_exa".to_string(),
852                arguments: McpSearchArgs {
853                    query: query.to_string(),
854                    num_results,
855                },
856            },
857        };
858
859        let client = reqwest::Client::new();
860        let res = client
861            .post("https://mcp.exa.ai/mcp")
862            .header("Content-Type", "application/json")
863            .header("Accept", "application/json, text/event-stream")
864            .json(&request)
865            .send()
866            .await?;
867
868        if !res.status().is_success() {
869            let error_text = res.text().await?;
870            return Err(anyhow::anyhow!("Search error: {}", error_text));
871        }
872
873        let mut stream = res.bytes_stream();
874        let mut buffer = Vec::new();
875        let timeout_duration = std::time::Duration::from_secs(10); // Wait at most 10s for first chunk
876
877        // We use a loop but breaks on first result.
878        // We also want to apply a timeout to receiving ANY chunk from the stream.
879        loop {
880            let chunk_future = stream.next();
881            match tokio::time::timeout(timeout_duration, chunk_future).await {
882                Ok(Some(chunk_result)) => {
883                    let chunk = chunk_result?;
884                    tracing::info!("WebSearchTool received chunk size: {}", chunk.len());
885                    buffer.extend_from_slice(&chunk);
886
887                    while let Some(idx) = buffer.iter().position(|&b| b == b'\n') {
888                        let line_bytes: Vec<u8> = buffer.drain(..=idx).collect();
889                        let line = String::from_utf8_lossy(&line_bytes);
890                        let line = line.trim();
891                        tracing::info!("WebSearchTool parsing line: {}", line);
892
893                        if let Some(data) = line.strip_prefix("data: ") {
894                            if let Ok(val) = serde_json::from_str::<Value>(data.trim()) {
895                                if let Some(content) = val
896                                    .get("result")
897                                    .and_then(|r| r.get("content"))
898                                    .and_then(|c| c.as_array())
899                                {
900                                    if let Some(first) = content.first() {
901                                        if let Some(text) =
902                                            first.get("text").and_then(|t| t.as_str())
903                                        {
904                                            return Ok(ToolResult {
905                                                output: text.to_string(),
906                                                metadata: json!({
907                                                    "query": query,
908                                                    "query_source": query_source,
909                                                    "query_hash": query_hash,
910                                                    "loop_guard_triggered": false
911                                                }),
912                                            });
913                                        }
914                                    }
915                                }
916                            }
917                        }
918                    }
919                }
920                Ok(None) => {
921                    tracing::info!("WebSearchTool stream ended without result.");
922                    break;
923                }
924                Err(_) => {
925                    tracing::warn!("WebSearchTool stream timed out waiting for chunk.");
926                    return Ok(ToolResult {
927                        output: "Search timed out. No results received.".to_string(),
928                        metadata: json!({
929                            "query": query,
930                            "error": "timeout",
931                            "query_source": query_source,
932                            "query_hash": query_hash,
933                            "loop_guard_triggered": false
934                        }),
935                    });
936                }
937            }
938        }
939
940        Ok(ToolResult {
941            output: "No search results found.".to_string(),
942            metadata: json!({
943                "query": query,
944                "query_source": query_source,
945                "query_hash": query_hash,
946                "loop_guard_triggered": false
947            }),
948        })
949    }
950}
951
952fn stable_hash(input: &str) -> String {
953    let mut hasher = DefaultHasher::new();
954    input.hash(&mut hasher);
955    format!("{:016x}", hasher.finish())
956}
957
958fn extract_websearch_query(args: &Value) -> Option<String> {
959    // Direct keys first.
960    const QUERY_KEYS: [&str; 5] = ["query", "q", "search_query", "searchQuery", "keywords"];
961    for key in QUERY_KEYS {
962        if let Some(query) = args.get(key).and_then(|v| v.as_str()) {
963            let trimmed = query.trim();
964            if !trimmed.is_empty() {
965                return Some(trimmed.to_string());
966            }
967        }
968    }
969
970    // Some tool-call envelopes nest args.
971    for container in ["arguments", "args", "input", "params"] {
972        if let Some(obj) = args.get(container) {
973            for key in QUERY_KEYS {
974                if let Some(query) = obj.get(key).and_then(|v| v.as_str()) {
975                    let trimmed = query.trim();
976                    if !trimmed.is_empty() {
977                        return Some(trimmed.to_string());
978                    }
979                }
980            }
981        }
982    }
983
984    // Last resort: plain string args.
985    args.as_str()
986        .map(str::trim)
987        .filter(|s| !s.is_empty())
988        .map(ToString::to_string)
989}
990
991fn extract_websearch_limit(args: &Value) -> Option<u64> {
992    let mut read_limit = |value: &Value| value.as_u64().map(|v| v.clamp(1, 10));
993
994    if let Some(limit) = args
995        .get("limit")
996        .and_then(&mut read_limit)
997        .or_else(|| args.get("numResults").and_then(&mut read_limit))
998        .or_else(|| args.get("num_results").and_then(&mut read_limit))
999    {
1000        return Some(limit);
1001    }
1002
1003    for container in ["arguments", "args", "input", "params"] {
1004        if let Some(obj) = args.get(container) {
1005            if let Some(limit) = obj
1006                .get("limit")
1007                .and_then(&mut read_limit)
1008                .or_else(|| obj.get("numResults").and_then(&mut read_limit))
1009                .or_else(|| obj.get("num_results").and_then(&mut read_limit))
1010            {
1011                return Some(limit);
1012            }
1013        }
1014    }
1015    None
1016}
1017
1018struct CodeSearchTool;
1019#[async_trait]
1020impl Tool for CodeSearchTool {
1021    fn schema(&self) -> ToolSchema {
1022        ToolSchema {
1023            name: "codesearch".to_string(),
1024            description: "Search code in workspace files".to_string(),
1025            input_schema: json!({"type":"object","properties":{"query":{"type":"string"},"path":{"type":"string"},"limit":{"type":"integer"}}}),
1026        }
1027    }
1028    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1029        let query = args["query"].as_str().unwrap_or("").trim();
1030        if query.is_empty() {
1031            return Ok(ToolResult {
1032                output: "missing query".to_string(),
1033                metadata: json!({"count": 0}),
1034            });
1035        }
1036        let root = args["path"].as_str().unwrap_or(".");
1037        if !is_path_allowed(root) {
1038            return Ok(ToolResult {
1039                output: "path denied by sandbox policy".to_string(),
1040                metadata: json!({"path": root}),
1041            });
1042        }
1043        let limit = args["limit"]
1044            .as_u64()
1045            .map(|v| v.clamp(1, 200) as usize)
1046            .unwrap_or(50);
1047        let mut hits = Vec::new();
1048        let lower = query.to_lowercase();
1049        for entry in WalkBuilder::new(root).build().flatten() {
1050            if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
1051                continue;
1052            }
1053            let path = entry.path();
1054            let ext = path.extension().and_then(|v| v.to_str()).unwrap_or("");
1055            if !matches!(
1056                ext,
1057                "rs" | "ts" | "tsx" | "js" | "jsx" | "py" | "md" | "toml" | "json"
1058            ) {
1059                continue;
1060            }
1061            if let Ok(content) = fs::read_to_string(path).await {
1062                for (idx, line) in content.lines().enumerate() {
1063                    if line.to_lowercase().contains(&lower) {
1064                        hits.push(format!("{}:{}:{}", path.display(), idx + 1, line.trim()));
1065                        if hits.len() >= limit {
1066                            break;
1067                        }
1068                    }
1069                }
1070            }
1071            if hits.len() >= limit {
1072                break;
1073            }
1074        }
1075        Ok(ToolResult {
1076            output: hits.join("\n"),
1077            metadata: json!({"count": hits.len(), "query": query}),
1078        })
1079    }
1080}
1081
1082struct TodoWriteTool;
1083#[async_trait]
1084impl Tool for TodoWriteTool {
1085    fn schema(&self) -> ToolSchema {
1086        ToolSchema {
1087            name: "todo_write".to_string(),
1088            description: "Update todo list".to_string(),
1089            input_schema: json!({
1090                "type":"object",
1091                "properties":{
1092                    "todos":{
1093                        "type":"array",
1094                        "items":{
1095                            "type":"object",
1096                            "properties":{
1097                                "id":{"type":"string"},
1098                                "content":{"type":"string"},
1099                                "text":{"type":"string"},
1100                                "status":{"type":"string"}
1101                            }
1102                        }
1103                    }
1104                }
1105            }),
1106        }
1107    }
1108    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1109        let todos = normalize_todos(args["todos"].as_array().cloned().unwrap_or_default());
1110        Ok(ToolResult {
1111            output: format!("todo list updated: {} items", todos.len()),
1112            metadata: json!({"todos": todos}),
1113        })
1114    }
1115}
1116
1117struct TaskTool;
1118#[async_trait]
1119impl Tool for TaskTool {
1120    fn schema(&self) -> ToolSchema {
1121        ToolSchema {
1122            name: "task".to_string(),
1123            description: "Create a subtask summary for orchestrator".to_string(),
1124            input_schema: json!({"type":"object","properties":{"description":{"type":"string"},"prompt":{"type":"string"}}}),
1125        }
1126    }
1127    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1128        let description = args["description"].as_str().unwrap_or("subtask");
1129        Ok(ToolResult {
1130            output: format!("Subtask planned: {description}"),
1131            metadata: json!({"description": description, "prompt": args["prompt"]}),
1132        })
1133    }
1134}
1135
1136struct QuestionTool;
1137#[async_trait]
1138impl Tool for QuestionTool {
1139    fn schema(&self) -> ToolSchema {
1140        ToolSchema {
1141            name: "question".to_string(),
1142            description: "Emit a question request for the user".to_string(),
1143            input_schema: json!({
1144                "type":"object",
1145                "properties":{
1146                    "questions":{
1147                        "type":"array",
1148                        "items":{
1149                            "type":"object",
1150                            "properties":{
1151                                "question":{"type":"string"},
1152                                "choices":{"type":"array","items":{"type":"string"}}
1153                            }
1154                        }
1155                    }
1156                }
1157            }),
1158        }
1159    }
1160    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1161        Ok(ToolResult {
1162            output: "Question requested. Use /question endpoints to respond.".to_string(),
1163            metadata: json!({"questions": args["questions"]}),
1164        })
1165    }
1166}
1167
1168struct SpawnAgentTool;
1169#[async_trait]
1170impl Tool for SpawnAgentTool {
1171    fn schema(&self) -> ToolSchema {
1172        ToolSchema {
1173            name: "spawn_agent".to_string(),
1174            description: "Spawn an agent-team instance through server policy enforcement."
1175                .to_string(),
1176            input_schema: json!({
1177                "type":"object",
1178                "properties":{
1179                    "missionID":{"type":"string"},
1180                    "parentInstanceID":{"type":"string"},
1181                    "templateID":{"type":"string"},
1182                    "role":{"type":"string","enum":["orchestrator","delegator","worker","watcher","reviewer","tester","committer"]},
1183                    "source":{"type":"string","enum":["tool_call"]},
1184                    "justification":{"type":"string"},
1185                    "budgetOverride":{"type":"object"}
1186                },
1187                "required":["role","justification"]
1188            }),
1189        }
1190    }
1191
1192    async fn execute(&self, _args: Value) -> anyhow::Result<ToolResult> {
1193        Ok(ToolResult {
1194            output: "spawn_agent must be executed through the engine runtime.".to_string(),
1195            metadata: json!({
1196                "ok": false,
1197                "code": "SPAWN_HOOK_UNAVAILABLE"
1198            }),
1199        })
1200    }
1201}
1202
1203struct MemorySearchTool;
1204#[async_trait]
1205impl Tool for MemorySearchTool {
1206    fn schema(&self) -> ToolSchema {
1207        ToolSchema {
1208            name: "memory_search".to_string(),
1209            description: "Search tandem memory with strict session/project scoping. Requires session_id and/or project_id; global search is blocked.".to_string(),
1210            input_schema: json!({
1211                "type":"object",
1212                "properties":{
1213                    "query":{"type":"string"},
1214                    "session_id":{"type":"string"},
1215                    "project_id":{"type":"string"},
1216                    "tier":{"type":"string","enum":["session","project"]},
1217                    "limit":{"type":"integer","minimum":1,"maximum":20},
1218                    "db_path":{"type":"string"}
1219                },
1220                "required":["query"]
1221            }),
1222        }
1223    }
1224
1225    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1226        let query = args
1227            .get("query")
1228            .or_else(|| args.get("q"))
1229            .and_then(|v| v.as_str())
1230            .map(str::trim)
1231            .unwrap_or("");
1232        if query.is_empty() {
1233            return Ok(ToolResult {
1234                output: "memory_search requires a non-empty query".to_string(),
1235                metadata: json!({"ok": false, "reason": "missing_query"}),
1236            });
1237        }
1238
1239        let session_id = args
1240            .get("session_id")
1241            .and_then(|v| v.as_str())
1242            .map(str::trim)
1243            .filter(|s| !s.is_empty())
1244            .map(ToString::to_string);
1245        let project_id = args
1246            .get("project_id")
1247            .and_then(|v| v.as_str())
1248            .map(str::trim)
1249            .filter(|s| !s.is_empty())
1250            .map(ToString::to_string);
1251        if session_id.is_none() && project_id.is_none() {
1252            return Ok(ToolResult {
1253                output: "memory_search requires at least one scope: session_id or project_id"
1254                    .to_string(),
1255                metadata: json!({"ok": false, "reason": "missing_scope"}),
1256            });
1257        }
1258
1259        let tier = match args
1260            .get("tier")
1261            .and_then(|v| v.as_str())
1262            .map(|s| s.trim().to_ascii_lowercase())
1263        {
1264            Some(t) if t == "session" => Some(MemoryTier::Session),
1265            Some(t) if t == "project" => Some(MemoryTier::Project),
1266            Some(t) if t == "global" => {
1267                return Ok(ToolResult {
1268                    output: "memory_search blocks global tier for strict isolation".to_string(),
1269                    metadata: json!({"ok": false, "reason": "global_scope_blocked"}),
1270                });
1271            }
1272            Some(_) => {
1273                return Ok(ToolResult {
1274                    output: "memory_search tier must be one of: session, project".to_string(),
1275                    metadata: json!({"ok": false, "reason": "invalid_tier"}),
1276                });
1277            }
1278            None => None,
1279        };
1280        if matches!(tier, Some(MemoryTier::Session)) && session_id.is_none() {
1281            return Ok(ToolResult {
1282                output: "tier=session requires session_id".to_string(),
1283                metadata: json!({"ok": false, "reason": "missing_session_scope"}),
1284            });
1285        }
1286        if matches!(tier, Some(MemoryTier::Project)) && project_id.is_none() {
1287            return Ok(ToolResult {
1288                output: "tier=project requires project_id".to_string(),
1289                metadata: json!({"ok": false, "reason": "missing_project_scope"}),
1290            });
1291        }
1292
1293        let limit = args
1294            .get("limit")
1295            .and_then(|v| v.as_i64())
1296            .unwrap_or(5)
1297            .clamp(1, 20);
1298
1299        let db_path = resolve_memory_db_path(&args);
1300        let db_exists = db_path.exists();
1301        if !db_exists {
1302            return Ok(ToolResult {
1303                output: "memory database not found".to_string(),
1304                metadata: json!({
1305                    "ok": false,
1306                    "reason": "memory_db_missing",
1307                    "db_path": db_path,
1308                }),
1309            });
1310        }
1311
1312        let manager = MemoryManager::new(&db_path).await?;
1313        let health = manager.embedding_health().await;
1314        if health.status != "ok" {
1315            return Ok(ToolResult {
1316                output: "memory embeddings unavailable; semantic search is disabled".to_string(),
1317                metadata: json!({
1318                    "ok": false,
1319                    "reason": "embeddings_unavailable",
1320                    "embedding_status": health.status,
1321                    "embedding_reason": health.reason,
1322                }),
1323            });
1324        }
1325
1326        let mut results: Vec<MemorySearchResult> = Vec::new();
1327        match tier {
1328            Some(MemoryTier::Session) => {
1329                results.extend(
1330                    manager
1331                        .search(
1332                            query,
1333                            Some(MemoryTier::Session),
1334                            project_id.as_deref(),
1335                            session_id.as_deref(),
1336                            Some(limit),
1337                        )
1338                        .await?,
1339                );
1340            }
1341            Some(MemoryTier::Project) => {
1342                results.extend(
1343                    manager
1344                        .search(
1345                            query,
1346                            Some(MemoryTier::Project),
1347                            project_id.as_deref(),
1348                            session_id.as_deref(),
1349                            Some(limit),
1350                        )
1351                        .await?,
1352                );
1353            }
1354            _ => {
1355                if session_id.is_some() {
1356                    results.extend(
1357                        manager
1358                            .search(
1359                                query,
1360                                Some(MemoryTier::Session),
1361                                project_id.as_deref(),
1362                                session_id.as_deref(),
1363                                Some(limit),
1364                            )
1365                            .await?,
1366                    );
1367                }
1368                if project_id.is_some() {
1369                    results.extend(
1370                        manager
1371                            .search(
1372                                query,
1373                                Some(MemoryTier::Project),
1374                                project_id.as_deref(),
1375                                session_id.as_deref(),
1376                                Some(limit),
1377                            )
1378                            .await?,
1379                    );
1380                }
1381            }
1382        }
1383
1384        let mut dedup: HashMap<String, MemorySearchResult> = HashMap::new();
1385        for result in results {
1386            match dedup.get(&result.chunk.id) {
1387                Some(existing) if existing.similarity >= result.similarity => {}
1388                _ => {
1389                    dedup.insert(result.chunk.id.clone(), result);
1390                }
1391            }
1392        }
1393        let mut merged = dedup.into_values().collect::<Vec<_>>();
1394        merged.sort_by(|a, b| b.similarity.total_cmp(&a.similarity));
1395        merged.truncate(limit as usize);
1396
1397        let output_rows = merged
1398            .iter()
1399            .map(|item| {
1400                json!({
1401                    "chunk_id": item.chunk.id,
1402                    "tier": item.chunk.tier.to_string(),
1403                    "session_id": item.chunk.session_id,
1404                    "project_id": item.chunk.project_id,
1405                    "source": item.chunk.source,
1406                    "similarity": item.similarity,
1407                    "content": item.chunk.content,
1408                    "created_at": item.chunk.created_at,
1409                })
1410            })
1411            .collect::<Vec<_>>();
1412
1413        Ok(ToolResult {
1414            output: serde_json::to_string_pretty(&output_rows).unwrap_or_default(),
1415            metadata: json!({
1416                "ok": true,
1417                "count": output_rows.len(),
1418                "limit": limit,
1419                "query": query,
1420                "session_id": session_id,
1421                "project_id": project_id,
1422                "embedding_status": health.status,
1423                "embedding_reason": health.reason,
1424                "strict_scope": true,
1425            }),
1426        })
1427    }
1428}
1429
1430fn resolve_memory_db_path(args: &Value) -> PathBuf {
1431    if let Some(path) = args
1432        .get("db_path")
1433        .and_then(|v| v.as_str())
1434        .map(str::trim)
1435        .filter(|s| !s.is_empty())
1436    {
1437        return PathBuf::from(path);
1438    }
1439    if let Ok(path) = std::env::var("TANDEM_MEMORY_DB_PATH") {
1440        let trimmed = path.trim();
1441        if !trimmed.is_empty() {
1442            return PathBuf::from(trimmed);
1443        }
1444    }
1445    PathBuf::from("memory.sqlite")
1446}
1447
1448struct SkillTool;
1449#[async_trait]
1450impl Tool for SkillTool {
1451    fn schema(&self) -> ToolSchema {
1452        ToolSchema {
1453            name: "skill".to_string(),
1454            description: "List or load installed Tandem skills. Call without name to list available skills; provide name to load full SKILL.md content.".to_string(),
1455            input_schema: json!({"type":"object","properties":{"name":{"type":"string"}}}),
1456        }
1457    }
1458    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1459        let workspace_root = std::env::current_dir().ok();
1460        let service = SkillService::for_workspace(workspace_root);
1461        let requested = args["name"].as_str().map(str::trim).unwrap_or("");
1462        let allowed_skills = parse_allowed_skills(&args);
1463
1464        if requested.is_empty() {
1465            let mut skills = service.list_skills().unwrap_or_default();
1466            if let Some(allowed) = &allowed_skills {
1467                skills.retain(|s| allowed.contains(&s.name));
1468            }
1469            if skills.is_empty() {
1470                return Ok(ToolResult {
1471                    output: "No skills available.".to_string(),
1472                    metadata: json!({"count": 0, "skills": []}),
1473                });
1474            }
1475            let mut lines = vec![
1476                "Available Tandem skills:".to_string(),
1477                "<available_skills>".to_string(),
1478            ];
1479            for skill in &skills {
1480                lines.push("  <skill>".to_string());
1481                lines.push(format!("    <name>{}</name>", skill.name));
1482                lines.push(format!(
1483                    "    <description>{}</description>",
1484                    escape_xml_text(&skill.description)
1485                ));
1486                lines.push(format!("    <location>{}</location>", skill.path));
1487                lines.push("  </skill>".to_string());
1488            }
1489            lines.push("</available_skills>".to_string());
1490            return Ok(ToolResult {
1491                output: lines.join("\n"),
1492                metadata: json!({"count": skills.len(), "skills": skills}),
1493            });
1494        }
1495
1496        if let Some(allowed) = &allowed_skills {
1497            if !allowed.contains(requested) {
1498                let mut allowed_list = allowed.iter().cloned().collect::<Vec<_>>();
1499                allowed_list.sort();
1500                return Ok(ToolResult {
1501                    output: format!(
1502                        "Skill \"{}\" is not enabled for this agent. Enabled skills: {}",
1503                        requested,
1504                        allowed_list.join(", ")
1505                    ),
1506                    metadata: json!({"name": requested, "enabled": allowed_list}),
1507                });
1508            }
1509        }
1510
1511        let loaded = service.load_skill(requested).map_err(anyhow::Error::msg)?;
1512        let Some(skill) = loaded else {
1513            let available = service
1514                .list_skills()
1515                .unwrap_or_default()
1516                .into_iter()
1517                .map(|s| s.name)
1518                .collect::<Vec<_>>();
1519            return Ok(ToolResult {
1520                output: format!(
1521                    "Skill \"{}\" not found. Available skills: {}",
1522                    requested,
1523                    if available.is_empty() {
1524                        "none".to_string()
1525                    } else {
1526                        available.join(", ")
1527                    }
1528                ),
1529                metadata: json!({"name": requested, "matches": [], "available": available}),
1530            });
1531        };
1532
1533        let files = skill
1534            .files
1535            .iter()
1536            .map(|f| format!("<file>{}</file>", f))
1537            .collect::<Vec<_>>()
1538            .join("\n");
1539        let output = [
1540            format!("<skill_content name=\"{}\">", skill.info.name),
1541            format!("# Skill: {}", skill.info.name),
1542            String::new(),
1543            skill.content.trim().to_string(),
1544            String::new(),
1545            format!("Base directory for this skill: {}", skill.base_dir),
1546            "Relative paths in this skill are resolved from this base directory.".to_string(),
1547            "Note: file list is sampled.".to_string(),
1548            String::new(),
1549            "<skill_files>".to_string(),
1550            files,
1551            "</skill_files>".to_string(),
1552            "</skill_content>".to_string(),
1553        ]
1554        .join("\n");
1555        Ok(ToolResult {
1556            output,
1557            metadata: json!({
1558                "name": skill.info.name,
1559                "dir": skill.base_dir,
1560                "path": skill.info.path
1561            }),
1562        })
1563    }
1564}
1565
1566fn escape_xml_text(input: &str) -> String {
1567    input
1568        .replace('&', "&amp;")
1569        .replace('<', "&lt;")
1570        .replace('>', "&gt;")
1571}
1572
1573fn parse_allowed_skills(args: &Value) -> Option<HashSet<String>> {
1574    let values = args
1575        .get("allowed_skills")
1576        .or_else(|| args.get("allowedSkills"))
1577        .and_then(|v| v.as_array())?;
1578    let out = values
1579        .iter()
1580        .filter_map(|v| v.as_str())
1581        .map(str::trim)
1582        .filter(|s| !s.is_empty())
1583        .map(ToString::to_string)
1584        .collect::<HashSet<_>>();
1585    Some(out)
1586}
1587
1588struct ApplyPatchTool;
1589#[async_trait]
1590impl Tool for ApplyPatchTool {
1591    fn schema(&self) -> ToolSchema {
1592        ToolSchema {
1593            name: "apply_patch".to_string(),
1594            description: "Validate patch text and report applicability".to_string(),
1595            input_schema: json!({"type":"object","properties":{"patchText":{"type":"string"}}}),
1596        }
1597    }
1598    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1599        let patch = args["patchText"].as_str().unwrap_or("");
1600        let has_begin = patch.contains("*** Begin Patch");
1601        let has_end = patch.contains("*** End Patch");
1602        let file_ops = patch
1603            .lines()
1604            .filter(|line| {
1605                line.starts_with("*** Add File:")
1606                    || line.starts_with("*** Update File:")
1607                    || line.starts_with("*** Delete File:")
1608            })
1609            .count();
1610        let valid = has_begin && has_end && file_ops > 0;
1611        Ok(ToolResult {
1612            output: if valid {
1613                "Patch format validated. Host-level patch application must execute this patch."
1614                    .to_string()
1615            } else {
1616                "Invalid patch format. Expected Begin/End markers and at least one file operation."
1617                    .to_string()
1618            },
1619            metadata: json!({"valid": valid, "fileOps": file_ops}),
1620        })
1621    }
1622}
1623
1624struct BatchTool;
1625#[async_trait]
1626impl Tool for BatchTool {
1627    fn schema(&self) -> ToolSchema {
1628        ToolSchema {
1629            name: "batch".to_string(),
1630            description: "Execute multiple tool calls sequentially".to_string(),
1631            input_schema: json!({
1632                "type":"object",
1633                "properties":{
1634                    "tool_calls":{
1635                        "type":"array",
1636                        "items":{
1637                            "type":"object",
1638                            "properties":{
1639                                "tool":{"type":"string"},
1640                                "name":{"type":"string"},
1641                                "args":{"type":"object"}
1642                            }
1643                        }
1644                    }
1645                }
1646            }),
1647        }
1648    }
1649    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1650        let calls = args["tool_calls"].as_array().cloned().unwrap_or_default();
1651        let registry = ToolRegistry::new();
1652        let mut outputs = Vec::new();
1653        for call in calls.iter().take(20) {
1654            let tool = call
1655                .get("tool")
1656                .or_else(|| call.get("name"))
1657                .and_then(|v| v.as_str())
1658                .unwrap_or("");
1659            if tool.is_empty() || tool == "batch" {
1660                continue;
1661            }
1662            let call_args = call.get("args").cloned().unwrap_or_else(|| json!({}));
1663            let result = registry.execute(tool, call_args).await?;
1664            outputs.push(json!({
1665                "tool": tool,
1666                "output": result.output,
1667                "metadata": result.metadata
1668            }));
1669        }
1670        let count = outputs.len();
1671        Ok(ToolResult {
1672            output: serde_json::to_string_pretty(&outputs).unwrap_or_default(),
1673            metadata: json!({"count": count}),
1674        })
1675    }
1676}
1677
1678struct LspTool;
1679#[async_trait]
1680impl Tool for LspTool {
1681    fn schema(&self) -> ToolSchema {
1682        ToolSchema {
1683            name: "lsp".to_string(),
1684            description: "LSP-like workspace diagnostics and symbol operations".to_string(),
1685            input_schema: json!({"type":"object","properties":{"operation":{"type":"string"},"filePath":{"type":"string"},"symbol":{"type":"string"},"query":{"type":"string"}}}),
1686        }
1687    }
1688    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1689        let operation = args["operation"].as_str().unwrap_or("symbols");
1690        let output = match operation {
1691            "diagnostics" => {
1692                let path = args["filePath"].as_str().unwrap_or("");
1693                if path.is_empty() || !is_path_allowed(path) {
1694                    "missing or unsafe filePath".to_string()
1695                } else {
1696                    diagnostics_for_path(path).await
1697                }
1698            }
1699            "definition" => {
1700                let symbol = args["symbol"].as_str().unwrap_or("");
1701                find_symbol_definition(symbol).await
1702            }
1703            "references" => {
1704                let symbol = args["symbol"].as_str().unwrap_or("");
1705                find_symbol_references(symbol).await
1706            }
1707            _ => {
1708                let query = args["query"]
1709                    .as_str()
1710                    .or_else(|| args["symbol"].as_str())
1711                    .unwrap_or("");
1712                list_symbols(query).await
1713            }
1714        };
1715        Ok(ToolResult {
1716            output,
1717            metadata: json!({"operation": operation}),
1718        })
1719    }
1720}
1721
1722#[allow(dead_code)]
1723fn _safe_path(path: &str) -> PathBuf {
1724    PathBuf::from(path)
1725}
1726
1727static TODO_SEQ: AtomicU64 = AtomicU64::new(1);
1728
1729fn normalize_todos(items: Vec<Value>) -> Vec<Value> {
1730    items
1731        .into_iter()
1732        .filter_map(|item| {
1733            let obj = item.as_object()?;
1734            let content = obj
1735                .get("content")
1736                .and_then(|v| v.as_str())
1737                .or_else(|| obj.get("text").and_then(|v| v.as_str()))
1738                .unwrap_or("")
1739                .trim()
1740                .to_string();
1741            if content.is_empty() {
1742                return None;
1743            }
1744            let id = obj
1745                .get("id")
1746                .and_then(|v| v.as_str())
1747                .filter(|s| !s.trim().is_empty())
1748                .map(ToString::to_string)
1749                .unwrap_or_else(|| format!("todo-{}", TODO_SEQ.fetch_add(1, Ordering::Relaxed)));
1750            let status = obj
1751                .get("status")
1752                .and_then(|v| v.as_str())
1753                .filter(|s| !s.trim().is_empty())
1754                .map(ToString::to_string)
1755                .unwrap_or_else(|| "pending".to_string());
1756            Some(json!({"id": id, "content": content, "status": status}))
1757        })
1758        .collect()
1759}
1760
1761async fn diagnostics_for_path(path: &str) -> String {
1762    let Ok(content) = fs::read_to_string(path).await else {
1763        return "File not found".to_string();
1764    };
1765    let mut issues = Vec::new();
1766    let mut balance = 0i64;
1767    for (idx, line) in content.lines().enumerate() {
1768        for ch in line.chars() {
1769            if ch == '{' {
1770                balance += 1;
1771            } else if ch == '}' {
1772                balance -= 1;
1773            }
1774        }
1775        if line.contains("TODO") {
1776            issues.push(format!("{path}:{}: TODO marker", idx + 1));
1777        }
1778    }
1779    if balance != 0 {
1780        issues.push(format!("{path}:1: Unbalanced braces"));
1781    }
1782    if issues.is_empty() {
1783        "No diagnostics.".to_string()
1784    } else {
1785        issues.join("\n")
1786    }
1787}
1788
1789async fn list_symbols(query: &str) -> String {
1790    let query = query.to_lowercase();
1791    let rust_fn = Regex::new(r"^\s*(pub\s+)?(async\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)")
1792        .unwrap_or_else(|_| Regex::new("$^").expect("regex"));
1793    let mut out = Vec::new();
1794    for entry in WalkBuilder::new(".").build().flatten() {
1795        if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
1796            continue;
1797        }
1798        let path = entry.path();
1799        let ext = path.extension().and_then(|v| v.to_str()).unwrap_or("");
1800        if !matches!(ext, "rs" | "ts" | "tsx" | "js" | "jsx" | "py") {
1801            continue;
1802        }
1803        if let Ok(content) = fs::read_to_string(path).await {
1804            for (idx, line) in content.lines().enumerate() {
1805                if let Some(captures) = rust_fn.captures(line) {
1806                    let name = captures
1807                        .get(3)
1808                        .map(|m| m.as_str().to_string())
1809                        .unwrap_or_default();
1810                    if query.is_empty() || name.to_lowercase().contains(&query) {
1811                        out.push(format!("{}:{}:fn {}", path.display(), idx + 1, name));
1812                        if out.len() >= 100 {
1813                            return out.join("\n");
1814                        }
1815                    }
1816                }
1817            }
1818        }
1819    }
1820    out.join("\n")
1821}
1822
1823async fn find_symbol_definition(symbol: &str) -> String {
1824    if symbol.trim().is_empty() {
1825        return "missing symbol".to_string();
1826    }
1827    let listed = list_symbols(symbol).await;
1828    listed
1829        .lines()
1830        .find(|line| line.ends_with(&format!("fn {symbol}")))
1831        .map(ToString::to_string)
1832        .unwrap_or_else(|| "symbol not found".to_string())
1833}
1834
1835#[cfg(test)]
1836mod tests {
1837    use super::*;
1838    use std::collections::HashSet;
1839
1840    #[test]
1841    fn validator_rejects_array_without_items() {
1842        let schemas = vec![ToolSchema {
1843            name: "bad".to_string(),
1844            description: "bad schema".to_string(),
1845            input_schema: json!({
1846                "type":"object",
1847                "properties":{"todos":{"type":"array"}}
1848            }),
1849        }];
1850        let err = validate_tool_schemas(&schemas).expect_err("expected schema validation failure");
1851        assert_eq!(err.tool_name, "bad");
1852        assert!(err.path.contains("properties.todos"));
1853    }
1854
1855    #[tokio::test]
1856    async fn registry_schemas_are_unique_and_valid() {
1857        let registry = ToolRegistry::new();
1858        let schemas = registry.list().await;
1859        validate_tool_schemas(&schemas).expect("registry tool schemas should validate");
1860        let unique = schemas
1861            .iter()
1862            .map(|schema| schema.name.as_str())
1863            .collect::<HashSet<_>>();
1864        assert_eq!(
1865            unique.len(),
1866            schemas.len(),
1867            "tool schemas must be unique by name"
1868        );
1869    }
1870
1871    #[test]
1872    fn websearch_query_extraction_accepts_aliases_and_nested_shapes() {
1873        let direct = json!({"query":"meaning of life"});
1874        assert_eq!(
1875            extract_websearch_query(&direct).as_deref(),
1876            Some("meaning of life")
1877        );
1878
1879        let alias = json!({"q":"hello"});
1880        assert_eq!(extract_websearch_query(&alias).as_deref(), Some("hello"));
1881
1882        let nested = json!({"arguments":{"search_query":"rust tokio"}});
1883        assert_eq!(
1884            extract_websearch_query(&nested).as_deref(),
1885            Some("rust tokio")
1886        );
1887
1888        let as_string = json!("find docs");
1889        assert_eq!(
1890            extract_websearch_query(&as_string).as_deref(),
1891            Some("find docs")
1892        );
1893    }
1894
1895    #[test]
1896    fn websearch_limit_extraction_clamps_and_reads_nested_fields() {
1897        assert_eq!(extract_websearch_limit(&json!({"limit": 100})), Some(10));
1898        assert_eq!(
1899            extract_websearch_limit(&json!({"arguments":{"numResults": 0}})),
1900            Some(1)
1901        );
1902        assert_eq!(
1903            extract_websearch_limit(&json!({"input":{"num_results": 6}})),
1904            Some(6)
1905        );
1906    }
1907
1908    #[test]
1909    fn test_html_stripping_and_markdown_reduction() {
1910        let html = r#"
1911            <!DOCTYPE html>
1912            <html>
1913            <head>
1914                <title>Test Page</title>
1915                <style>
1916                    body { color: red; }
1917                </style>
1918                <script>
1919                    console.log("noisy script");
1920                </script>
1921            </head>
1922            <body>
1923                <h1>Hello World</h1>
1924                <p>This is a <a href="https://example.com">link</a>.</p>
1925                <noscript>Enable JS</noscript>
1926            </body>
1927            </html>
1928        "#;
1929
1930        let cleaned = strip_html_noise(html);
1931        assert!(!cleaned.contains("noisy script"));
1932        assert!(!cleaned.contains("color: red"));
1933        assert!(!cleaned.contains("Enable JS"));
1934        assert!(cleaned.contains("Hello World"));
1935
1936        let markdown = html2md::parse_html(&cleaned);
1937        let text = markdown_to_text(&markdown);
1938
1939        // Raw length includes all the noise
1940        let raw_len = html.len();
1941        // Markdown length should be significantly smaller
1942        let md_len = markdown.len();
1943
1944        println!("Raw: {}, Markdown: {}", raw_len, md_len);
1945        assert!(
1946            md_len < raw_len / 2,
1947            "Markdown should be < 50% of raw HTML size"
1948        );
1949        assert!(text.contains("Hello World"));
1950        assert!(text.contains("link"));
1951    }
1952
1953    #[tokio::test]
1954    async fn memory_search_requires_scope() {
1955        let tool = MemorySearchTool;
1956        let result = tool
1957            .execute(json!({"query": "deployment strategy"}))
1958            .await
1959            .expect("memory_search should return ToolResult");
1960        assert!(result.output.contains("requires at least one scope"));
1961        assert_eq!(result.metadata["ok"], json!(false));
1962        assert_eq!(result.metadata["reason"], json!("missing_scope"));
1963    }
1964
1965    #[tokio::test]
1966    async fn memory_search_blocks_global_tier() {
1967        let tool = MemorySearchTool;
1968        let result = tool
1969            .execute(json!({
1970                "query": "deployment strategy",
1971                "session_id": "ses_1",
1972                "tier": "global"
1973            }))
1974            .await
1975            .expect("memory_search should return ToolResult");
1976        assert!(result.output.contains("blocks global tier"));
1977        assert_eq!(result.metadata["ok"], json!(false));
1978        assert_eq!(result.metadata["reason"], json!("global_scope_blocked"));
1979    }
1980}
1981
1982async fn find_symbol_references(symbol: &str) -> String {
1983    if symbol.trim().is_empty() {
1984        return "missing symbol".to_string();
1985    }
1986    let escaped = regex::escape(symbol);
1987    let re = Regex::new(&format!(r"\b{}\b", escaped));
1988    let Ok(re) = re else {
1989        return "invalid symbol".to_string();
1990    };
1991    let mut refs = Vec::new();
1992    for entry in WalkBuilder::new(".").build().flatten() {
1993        if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
1994            continue;
1995        }
1996        let path = entry.path();
1997        if let Ok(content) = fs::read_to_string(path).await {
1998            for (idx, line) in content.lines().enumerate() {
1999                if re.is_match(line) {
2000                    refs.push(format!("{}:{}:{}", path.display(), idx + 1, line.trim()));
2001                    if refs.len() >= 200 {
2002                        return refs.join("\n");
2003                    }
2004                }
2005            }
2006        }
2007    }
2008    refs.join("\n")
2009}