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::{AtomicU64, Ordering};
6use std::sync::Arc;
7
8use async_trait::async_trait;
9use ignore::WalkBuilder;
10use regex::Regex;
11use serde_json::{json, Value};
12use tandem_skills::SkillService;
13use tokio::fs;
14use tokio::process::Command;
15use tokio::sync::RwLock;
16use tokio_util::sync::CancellationToken;
17
18use futures_util::StreamExt;
19use tandem_memory::types::{MemorySearchResult, MemoryTier};
20use tandem_memory::MemoryManager;
21use tandem_types::{ToolResult, ToolSchema};
22
23#[async_trait]
24pub trait Tool: Send + Sync {
25    fn schema(&self) -> ToolSchema;
26    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult>;
27    async fn execute_with_cancel(
28        &self,
29        args: Value,
30        _cancel: CancellationToken,
31    ) -> anyhow::Result<ToolResult> {
32        self.execute(args).await
33    }
34}
35
36#[derive(Clone)]
37pub struct ToolRegistry {
38    tools: Arc<RwLock<HashMap<String, Arc<dyn Tool>>>>,
39}
40
41impl ToolRegistry {
42    pub fn new() -> Self {
43        let mut map: HashMap<String, Arc<dyn Tool>> = HashMap::new();
44        map.insert("bash".to_string(), Arc::new(BashTool));
45        map.insert("read".to_string(), Arc::new(ReadTool));
46        map.insert("write".to_string(), Arc::new(WriteTool));
47        map.insert("edit".to_string(), Arc::new(EditTool));
48        map.insert("glob".to_string(), Arc::new(GlobTool));
49        map.insert("grep".to_string(), Arc::new(GrepTool));
50        map.insert("webfetch".to_string(), Arc::new(WebFetchTool));
51        map.insert("webfetch_html".to_string(), Arc::new(WebFetchHtmlTool));
52        map.insert("mcp_debug".to_string(), Arc::new(McpDebugTool));
53        map.insert("websearch".to_string(), Arc::new(WebSearchTool));
54        map.insert("codesearch".to_string(), Arc::new(CodeSearchTool));
55        let todo_tool: Arc<dyn Tool> = Arc::new(TodoWriteTool);
56        map.insert("todo_write".to_string(), todo_tool.clone());
57        map.insert("todowrite".to_string(), todo_tool.clone());
58        map.insert("update_todo_list".to_string(), todo_tool);
59        map.insert("task".to_string(), Arc::new(TaskTool));
60        map.insert("question".to_string(), Arc::new(QuestionTool));
61        map.insert("spawn_agent".to_string(), Arc::new(SpawnAgentTool));
62        map.insert("skill".to_string(), Arc::new(SkillTool));
63        map.insert("memory_store".to_string(), Arc::new(MemoryStoreTool));
64        map.insert("memory_list".to_string(), Arc::new(MemoryListTool));
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 register_tool(&self, name: String, tool: Arc<dyn Tool>) {
85        self.tools.write().await.insert(name, tool);
86    }
87
88    pub async fn unregister_tool(&self, name: &str) -> bool {
89        self.tools.write().await.remove(name).is_some()
90    }
91
92    pub async fn unregister_by_prefix(&self, prefix: &str) -> usize {
93        let mut tools = self.tools.write().await;
94        let keys = tools
95            .keys()
96            .filter(|name| name.starts_with(prefix))
97            .cloned()
98            .collect::<Vec<_>>();
99        let removed = keys.len();
100        for key in keys {
101            tools.remove(&key);
102        }
103        removed
104    }
105
106    pub async fn execute(&self, name: &str, args: Value) -> anyhow::Result<ToolResult> {
107        let tool = {
108            let tools = self.tools.read().await;
109            resolve_registered_tool(&tools, name)
110        };
111        let Some(tool) = tool else {
112            return Ok(ToolResult {
113                output: format!("Unknown tool: {name}"),
114                metadata: json!({}),
115            });
116        };
117        tool.execute(args).await
118    }
119
120    pub async fn execute_with_cancel(
121        &self,
122        name: &str,
123        args: Value,
124        cancel: CancellationToken,
125    ) -> anyhow::Result<ToolResult> {
126        let tool = {
127            let tools = self.tools.read().await;
128            resolve_registered_tool(&tools, name)
129        };
130        let Some(tool) = tool else {
131            return Ok(ToolResult {
132                output: format!("Unknown tool: {name}"),
133                metadata: json!({}),
134            });
135        };
136        tool.execute_with_cancel(args, cancel).await
137    }
138}
139
140fn canonical_tool_name(name: &str) -> String {
141    match name.trim().to_ascii_lowercase().replace('-', "_").as_str() {
142        "todowrite" | "update_todo_list" | "update_todos" => "todo_write".to_string(),
143        "run_command" | "shell" | "powershell" | "cmd" => "bash".to_string(),
144        other => other.to_string(),
145    }
146}
147
148fn strip_known_tool_namespace(name: &str) -> Option<String> {
149    const PREFIXES: [&str; 8] = [
150        "default_api:",
151        "default_api.",
152        "functions.",
153        "function.",
154        "tools.",
155        "tool.",
156        "builtin:",
157        "builtin.",
158    ];
159    for prefix in PREFIXES {
160        if let Some(rest) = name.strip_prefix(prefix) {
161            let trimmed = rest.trim();
162            if !trimmed.is_empty() {
163                return Some(trimmed.to_string());
164            }
165        }
166    }
167    None
168}
169
170fn resolve_registered_tool(
171    tools: &HashMap<String, Arc<dyn Tool>>,
172    requested_name: &str,
173) -> Option<Arc<dyn Tool>> {
174    let canonical = canonical_tool_name(requested_name);
175    if let Some(tool) = tools.get(&canonical) {
176        return Some(tool.clone());
177    }
178    if let Some(stripped) = strip_known_tool_namespace(&canonical) {
179        let stripped = canonical_tool_name(&stripped);
180        if let Some(tool) = tools.get(&stripped) {
181            return Some(tool.clone());
182        }
183    }
184    None
185}
186
187fn is_batch_wrapper_tool_name(name: &str) -> bool {
188    matches!(
189        canonical_tool_name(name).as_str(),
190        "default_api" | "default" | "api" | "function" | "functions" | "tool" | "tools"
191    )
192}
193
194fn non_empty_batch_str(value: Option<&Value>) -> Option<&str> {
195    value
196        .and_then(|v| v.as_str())
197        .map(str::trim)
198        .filter(|s| !s.is_empty())
199}
200
201fn resolve_batch_call_tool_name(call: &Value) -> Option<String> {
202    let tool = non_empty_batch_str(call.get("tool"))
203        .or_else(|| {
204            call.get("tool")
205                .and_then(|v| v.as_object())
206                .and_then(|obj| non_empty_batch_str(obj.get("name")))
207        })
208        .or_else(|| {
209            call.get("function")
210                .and_then(|v| v.as_object())
211                .and_then(|obj| non_empty_batch_str(obj.get("tool")))
212        })
213        .or_else(|| {
214            call.get("function_call")
215                .and_then(|v| v.as_object())
216                .and_then(|obj| non_empty_batch_str(obj.get("tool")))
217        })
218        .or_else(|| {
219            call.get("call")
220                .and_then(|v| v.as_object())
221                .and_then(|obj| non_empty_batch_str(obj.get("tool")))
222        });
223    let name = non_empty_batch_str(call.get("name"))
224        .or_else(|| {
225            call.get("function")
226                .and_then(|v| v.as_object())
227                .and_then(|obj| non_empty_batch_str(obj.get("name")))
228        })
229        .or_else(|| {
230            call.get("function_call")
231                .and_then(|v| v.as_object())
232                .and_then(|obj| non_empty_batch_str(obj.get("name")))
233        })
234        .or_else(|| {
235            call.get("call")
236                .and_then(|v| v.as_object())
237                .and_then(|obj| non_empty_batch_str(obj.get("name")))
238        })
239        .or_else(|| {
240            call.get("tool")
241                .and_then(|v| v.as_object())
242                .and_then(|obj| non_empty_batch_str(obj.get("name")))
243        });
244
245    match (tool, name) {
246        (Some(t), Some(n)) => {
247            if is_batch_wrapper_tool_name(t) {
248                Some(n.to_string())
249            } else if let Some(stripped) = strip_known_tool_namespace(t) {
250                Some(stripped)
251            } else {
252                Some(t.to_string())
253            }
254        }
255        (Some(t), None) => {
256            if is_batch_wrapper_tool_name(t) {
257                None
258            } else if let Some(stripped) = strip_known_tool_namespace(t) {
259                Some(stripped)
260            } else {
261                Some(t.to_string())
262            }
263        }
264        (None, Some(n)) => Some(n.to_string()),
265        (None, None) => None,
266    }
267}
268
269impl Default for ToolRegistry {
270    fn default() -> Self {
271        Self::new()
272    }
273}
274
275#[derive(Debug, Clone, PartialEq, Eq)]
276pub struct ToolSchemaValidationError {
277    pub tool_name: String,
278    pub path: String,
279    pub reason: String,
280}
281
282impl std::fmt::Display for ToolSchemaValidationError {
283    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284        write!(
285            f,
286            "invalid tool schema `{}` at `{}`: {}",
287            self.tool_name, self.path, self.reason
288        )
289    }
290}
291
292impl std::error::Error for ToolSchemaValidationError {}
293
294pub fn validate_tool_schemas(schemas: &[ToolSchema]) -> Result<(), ToolSchemaValidationError> {
295    for schema in schemas {
296        validate_schema_node(&schema.name, "$", &schema.input_schema)?;
297    }
298    Ok(())
299}
300
301fn validate_schema_node(
302    tool_name: &str,
303    path: &str,
304    value: &Value,
305) -> Result<(), ToolSchemaValidationError> {
306    let Some(obj) = value.as_object() else {
307        if let Some(arr) = value.as_array() {
308            for (idx, item) in arr.iter().enumerate() {
309                validate_schema_node(tool_name, &format!("{path}[{idx}]"), item)?;
310            }
311        }
312        return Ok(());
313    };
314
315    if obj.get("type").and_then(|t| t.as_str()) == Some("array") && !obj.contains_key("items") {
316        return Err(ToolSchemaValidationError {
317            tool_name: tool_name.to_string(),
318            path: path.to_string(),
319            reason: "array schema missing items".to_string(),
320        });
321    }
322
323    if let Some(items) = obj.get("items") {
324        validate_schema_node(tool_name, &format!("{path}.items"), items)?;
325    }
326    if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
327        for (key, child) in props {
328            validate_schema_node(tool_name, &format!("{path}.properties.{key}"), child)?;
329        }
330    }
331    if let Some(additional_props) = obj.get("additionalProperties") {
332        validate_schema_node(
333            tool_name,
334            &format!("{path}.additionalProperties"),
335            additional_props,
336        )?;
337    }
338    if let Some(one_of) = obj.get("oneOf").and_then(|v| v.as_array()) {
339        for (idx, child) in one_of.iter().enumerate() {
340            validate_schema_node(tool_name, &format!("{path}.oneOf[{idx}]"), child)?;
341        }
342    }
343    if let Some(any_of) = obj.get("anyOf").and_then(|v| v.as_array()) {
344        for (idx, child) in any_of.iter().enumerate() {
345            validate_schema_node(tool_name, &format!("{path}.anyOf[{idx}]"), child)?;
346        }
347    }
348    if let Some(all_of) = obj.get("allOf").and_then(|v| v.as_array()) {
349        for (idx, child) in all_of.iter().enumerate() {
350            validate_schema_node(tool_name, &format!("{path}.allOf[{idx}]"), child)?;
351        }
352    }
353
354    Ok(())
355}
356
357fn workspace_root_from_args(args: &Value) -> Option<PathBuf> {
358    args.get("__workspace_root")
359        .and_then(|v| v.as_str())
360        .map(str::trim)
361        .filter(|s| !s.is_empty())
362        .map(PathBuf::from)
363}
364
365fn effective_cwd_from_args(args: &Value) -> PathBuf {
366    args.get("__effective_cwd")
367        .and_then(|v| v.as_str())
368        .map(str::trim)
369        .filter(|s| !s.is_empty())
370        .map(PathBuf::from)
371        .or_else(|| workspace_root_from_args(args))
372        .or_else(|| std::env::current_dir().ok())
373        .unwrap_or_else(|| PathBuf::from("."))
374}
375
376fn normalize_path_for_compare(path: &Path) -> PathBuf {
377    let mut normalized = PathBuf::new();
378    for component in path.components() {
379        match component {
380            std::path::Component::CurDir => {}
381            std::path::Component::ParentDir => {
382                let _ = normalized.pop();
383            }
384            other => normalized.push(other.as_os_str()),
385        }
386    }
387    normalized
388}
389
390fn normalize_existing_or_lexical(path: &Path) -> PathBuf {
391    path.canonicalize()
392        .unwrap_or_else(|_| normalize_path_for_compare(path))
393}
394
395fn is_within_workspace_root(path: &Path, workspace_root: &Path) -> bool {
396    let candidate = normalize_existing_or_lexical(path);
397    let root = normalize_existing_or_lexical(workspace_root);
398    candidate.starts_with(root)
399}
400
401fn resolve_tool_path(path: &str, args: &Value) -> Option<PathBuf> {
402    let trimmed = path.trim();
403    if trimmed.is_empty() {
404        return None;
405    }
406    if trimmed == "." || trimmed == "./" || trimmed == ".\\" {
407        let cwd = effective_cwd_from_args(args);
408        if let Some(workspace_root) = workspace_root_from_args(args) {
409            if !is_within_workspace_root(&cwd, &workspace_root) {
410                return None;
411            }
412        }
413        return Some(cwd);
414    }
415    if is_root_only_path_token(trimmed) || is_malformed_tool_path_token(trimmed) {
416        return None;
417    }
418    let raw = Path::new(trimmed);
419    if !raw.is_absolute()
420        && raw
421            .components()
422            .any(|c| matches!(c, std::path::Component::ParentDir))
423    {
424        return None;
425    }
426
427    let resolved = if raw.is_absolute() {
428        raw.to_path_buf()
429    } else {
430        effective_cwd_from_args(args).join(raw)
431    };
432
433    if let Some(workspace_root) = workspace_root_from_args(args) {
434        if !is_within_workspace_root(&resolved, &workspace_root) {
435            return None;
436        }
437    } else if raw.is_absolute() {
438        return None;
439    }
440
441    Some(resolved)
442}
443
444fn resolve_walk_root(path: &str, args: &Value) -> Option<PathBuf> {
445    let trimmed = path.trim();
446    if trimmed.is_empty() {
447        return None;
448    }
449    if is_malformed_tool_path_token(trimmed) {
450        return None;
451    }
452    resolve_tool_path(path, args)
453}
454
455fn is_root_only_path_token(path: &str) -> bool {
456    if matches!(path, "/" | "\\" | "." | ".." | "~") {
457        return true;
458    }
459    let bytes = path.as_bytes();
460    if bytes.len() == 2 && bytes[1] == b':' && (bytes[0] as char).is_ascii_alphabetic() {
461        return true;
462    }
463    if bytes.len() == 3
464        && bytes[1] == b':'
465        && (bytes[0] as char).is_ascii_alphabetic()
466        && (bytes[2] == b'\\' || bytes[2] == b'/')
467    {
468        return true;
469    }
470    false
471}
472
473fn is_malformed_tool_path_token(path: &str) -> bool {
474    let lower = path.to_ascii_lowercase();
475    if lower.contains("<tool_call")
476        || lower.contains("</tool_call")
477        || lower.contains("<function=")
478        || lower.contains("<parameter=")
479        || lower.contains("</function>")
480        || lower.contains("</parameter>")
481    {
482        return true;
483    }
484    if path.contains('\n') || path.contains('\r') {
485        return true;
486    }
487    if path.contains('*') || path.contains('?') {
488        return true;
489    }
490    false
491}
492
493fn is_document_file(path: &Path) -> bool {
494    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
495        matches!(
496            ext.to_lowercase().as_str(),
497            "pdf" | "docx" | "pptx" | "xlsx" | "xls" | "ods" | "xlsb" | "rtf"
498        )
499    } else {
500        false
501    }
502}
503
504struct BashTool;
505#[async_trait]
506impl Tool for BashTool {
507    fn schema(&self) -> ToolSchema {
508        ToolSchema {
509            name: "bash".to_string(),
510            description: "Run shell command".to_string(),
511            input_schema: json!({
512                "type":"object",
513                "properties":{
514                    "command":{"type":"string"}
515                },
516                "required":["command"]
517            }),
518        }
519    }
520    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
521        let cmd = args["command"].as_str().unwrap_or("").trim();
522        if cmd.is_empty() {
523            anyhow::bail!("BASH_COMMAND_MISSING");
524        }
525        #[cfg(windows)]
526        let shell = match build_shell_command(cmd) {
527            ShellCommandPlan::Execute(plan) => plan,
528            ShellCommandPlan::Blocked(result) => return Ok(result),
529        };
530        #[cfg(not(windows))]
531        let ShellCommandPlan::Execute(shell) = build_shell_command(cmd);
532        let ShellExecutionPlan {
533            mut command,
534            translated_command,
535            os_guardrail_applied,
536            guardrail_reason,
537        } = shell;
538        let effective_cwd = effective_cwd_from_args(&args);
539        command.current_dir(&effective_cwd);
540        if let Some(env) = args.get("env").and_then(|v| v.as_object()) {
541            for (k, v) in env {
542                if let Some(value) = v.as_str() {
543                    command.env(k, value);
544                }
545            }
546        }
547        let output = command.output().await?;
548        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
549        let metadata = shell_metadata(
550            translated_command.as_deref(),
551            os_guardrail_applied,
552            guardrail_reason.as_deref(),
553            stderr,
554        );
555        let mut metadata = metadata;
556        if let Some(obj) = metadata.as_object_mut() {
557            obj.insert(
558                "effective_cwd".to_string(),
559                Value::String(effective_cwd.to_string_lossy().to_string()),
560            );
561            if let Some(workspace_root) = workspace_root_from_args(&args) {
562                obj.insert(
563                    "workspace_root".to_string(),
564                    Value::String(workspace_root.to_string_lossy().to_string()),
565                );
566            }
567        }
568        Ok(ToolResult {
569            output: String::from_utf8_lossy(&output.stdout).to_string(),
570            metadata,
571        })
572    }
573
574    async fn execute_with_cancel(
575        &self,
576        args: Value,
577        cancel: CancellationToken,
578    ) -> anyhow::Result<ToolResult> {
579        let cmd = args["command"].as_str().unwrap_or("").trim();
580        if cmd.is_empty() {
581            anyhow::bail!("BASH_COMMAND_MISSING");
582        }
583        #[cfg(windows)]
584        let shell = match build_shell_command(cmd) {
585            ShellCommandPlan::Execute(plan) => plan,
586            ShellCommandPlan::Blocked(result) => return Ok(result),
587        };
588        #[cfg(not(windows))]
589        let ShellCommandPlan::Execute(shell) = build_shell_command(cmd);
590        let ShellExecutionPlan {
591            mut command,
592            translated_command,
593            os_guardrail_applied,
594            guardrail_reason,
595        } = shell;
596        let effective_cwd = effective_cwd_from_args(&args);
597        command.current_dir(&effective_cwd);
598        if let Some(env) = args.get("env").and_then(|v| v.as_object()) {
599            for (k, v) in env {
600                if let Some(value) = v.as_str() {
601                    command.env(k, value);
602                }
603            }
604        }
605        command.stdout(Stdio::null());
606        command.stderr(Stdio::piped());
607        let mut child = command.spawn()?;
608        let status = tokio::select! {
609            _ = cancel.cancelled() => {
610                let _ = child.kill().await;
611                return Ok(ToolResult {
612                    output: "command cancelled".to_string(),
613                    metadata: json!({"cancelled": true}),
614                });
615            }
616            result = child.wait() => result?
617        };
618        let stderr = match child.stderr.take() {
619            Some(mut handle) => {
620                use tokio::io::AsyncReadExt;
621                let mut buf = Vec::new();
622                let _ = handle.read_to_end(&mut buf).await;
623                String::from_utf8_lossy(&buf).to_string()
624            }
625            None => String::new(),
626        };
627        let mut metadata = shell_metadata(
628            translated_command.as_deref(),
629            os_guardrail_applied,
630            guardrail_reason.as_deref(),
631            stderr,
632        );
633        if let Some(obj) = metadata.as_object_mut() {
634            obj.insert("exit_code".to_string(), json!(status.code()));
635            obj.insert(
636                "effective_cwd".to_string(),
637                Value::String(effective_cwd.to_string_lossy().to_string()),
638            );
639            if let Some(workspace_root) = workspace_root_from_args(&args) {
640                obj.insert(
641                    "workspace_root".to_string(),
642                    Value::String(workspace_root.to_string_lossy().to_string()),
643                );
644            }
645        }
646        Ok(ToolResult {
647            output: format!("command exited: {}", status),
648            metadata,
649        })
650    }
651}
652
653struct ShellExecutionPlan {
654    command: Command,
655    translated_command: Option<String>,
656    os_guardrail_applied: bool,
657    guardrail_reason: Option<String>,
658}
659
660fn shell_metadata(
661    translated_command: Option<&str>,
662    os_guardrail_applied: bool,
663    guardrail_reason: Option<&str>,
664    stderr: String,
665) -> Value {
666    let mut metadata = json!({
667        "stderr": stderr,
668        "os_guardrail_applied": os_guardrail_applied,
669    });
670    if let Some(obj) = metadata.as_object_mut() {
671        if let Some(translated) = translated_command {
672            obj.insert(
673                "translated_command".to_string(),
674                Value::String(translated.to_string()),
675            );
676        }
677        if let Some(reason) = guardrail_reason {
678            obj.insert(
679                "guardrail_reason".to_string(),
680                Value::String(reason.to_string()),
681            );
682        }
683    }
684    metadata
685}
686
687enum ShellCommandPlan {
688    Execute(ShellExecutionPlan),
689    #[cfg(windows)]
690    Blocked(ToolResult),
691}
692
693fn build_shell_command(raw_cmd: &str) -> ShellCommandPlan {
694    #[cfg(windows)]
695    {
696        let reason = windows_guardrail_reason(raw_cmd);
697        let translated = translate_windows_shell_command(raw_cmd);
698        let translated_applied = translated.is_some();
699        if let Some(reason) = reason {
700            if translated.is_none() {
701                return ShellCommandPlan::Blocked(ToolResult {
702                    output: format!(
703                        "Shell command blocked on Windows ({reason}). Use cross-platform tools (`read`, `glob`, `grep`) or PowerShell-native syntax."
704                    ),
705                    metadata: json!({
706                        "os_guardrail_applied": true,
707                        "guardrail_reason": reason,
708                        "blocked": true
709                    }),
710                });
711            }
712        }
713        let effective = translated.clone().unwrap_or_else(|| raw_cmd.to_string());
714        let mut command = Command::new("powershell");
715        command.args(["-NoProfile", "-Command", &effective]);
716        return ShellCommandPlan::Execute(ShellExecutionPlan {
717            command,
718            translated_command: translated,
719            os_guardrail_applied: reason.is_some() || translated_applied,
720            guardrail_reason: reason.map(str::to_string),
721        });
722    }
723
724    #[allow(unreachable_code)]
725    {
726        let mut command = Command::new("sh");
727        command.args(["-lc", raw_cmd]);
728        ShellCommandPlan::Execute(ShellExecutionPlan {
729            command,
730            translated_command: None,
731            os_guardrail_applied: false,
732            guardrail_reason: None,
733        })
734    }
735}
736
737#[cfg(any(windows, test))]
738fn translate_windows_shell_command(raw_cmd: &str) -> Option<String> {
739    let trimmed = raw_cmd.trim();
740    if trimmed.is_empty() {
741        return None;
742    }
743    let lowered = trimmed.to_ascii_lowercase();
744    if lowered.starts_with("ls") {
745        return translate_windows_ls_command(trimmed);
746    }
747    if lowered.starts_with("find ") {
748        return translate_windows_find_command(trimmed);
749    }
750    None
751}
752
753#[cfg(any(windows, test))]
754fn translate_windows_ls_command(trimmed: &str) -> Option<String> {
755    let mut force = false;
756    let mut paths: Vec<&str> = Vec::new();
757    for token in trimmed.split_whitespace().skip(1) {
758        if token.starts_with('-') {
759            let flags = token.trim_start_matches('-').to_ascii_lowercase();
760            if flags.contains('a') {
761                force = true;
762            }
763            continue;
764        }
765        paths.push(token);
766    }
767
768    let mut translated = String::from("Get-ChildItem");
769    if force {
770        translated.push_str(" -Force");
771    }
772    if !paths.is_empty() {
773        translated.push_str(" -Path ");
774        translated.push_str(&quote_powershell_single(&paths.join(" ")));
775    }
776    Some(translated)
777}
778
779#[cfg(any(windows, test))]
780fn translate_windows_find_command(trimmed: &str) -> Option<String> {
781    let tokens: Vec<&str> = trimmed.split_whitespace().collect();
782    if tokens.is_empty() || !tokens[0].eq_ignore_ascii_case("find") {
783        return None;
784    }
785
786    let mut idx = 1usize;
787    let mut path = ".".to_string();
788    let mut file_only = false;
789    let mut patterns: Vec<String> = Vec::new();
790
791    if idx < tokens.len() && !tokens[idx].starts_with('-') {
792        path = normalize_shell_token(tokens[idx]);
793        idx += 1;
794    }
795
796    while idx < tokens.len() {
797        let token = tokens[idx].to_ascii_lowercase();
798        match token.as_str() {
799            "-type" => {
800                if idx + 1 < tokens.len() && tokens[idx + 1].eq_ignore_ascii_case("f") {
801                    file_only = true;
802                }
803                idx += 2;
804            }
805            "-name" => {
806                if idx + 1 < tokens.len() {
807                    let pattern = normalize_shell_token(tokens[idx + 1]);
808                    if !pattern.is_empty() {
809                        patterns.push(pattern);
810                    }
811                }
812                idx += 2;
813            }
814            "-o" | "-or" | "(" | ")" => {
815                idx += 1;
816            }
817            _ => {
818                idx += 1;
819            }
820        }
821    }
822
823    let mut translated = format!("Get-ChildItem -Path {}", quote_powershell_single(&path));
824    translated.push_str(" -Recurse");
825    if file_only {
826        translated.push_str(" -File");
827    }
828
829    if patterns.len() == 1 {
830        translated.push_str(" -Filter ");
831        translated.push_str(&quote_powershell_single(&patterns[0]));
832    } else if patterns.len() > 1 {
833        translated.push_str(" -Include ");
834        let include_list = patterns
835            .iter()
836            .map(|p| quote_powershell_single(p))
837            .collect::<Vec<_>>()
838            .join(",");
839        translated.push_str(&include_list);
840    }
841
842    Some(translated)
843}
844
845#[cfg(any(windows, test))]
846fn normalize_shell_token(token: &str) -> String {
847    let trimmed = token.trim();
848    if trimmed.len() >= 2
849        && ((trimmed.starts_with('"') && trimmed.ends_with('"'))
850            || (trimmed.starts_with('\'') && trimmed.ends_with('\'')))
851    {
852        return trimmed[1..trimmed.len() - 1].to_string();
853    }
854    trimmed.to_string()
855}
856
857#[cfg(any(windows, test))]
858fn quote_powershell_single(input: &str) -> String {
859    format!("'{}'", input.replace('\'', "''"))
860}
861
862#[cfg(any(windows, test))]
863fn windows_guardrail_reason(raw_cmd: &str) -> Option<&'static str> {
864    let trimmed = raw_cmd.trim().to_ascii_lowercase();
865    if trimmed.is_empty() {
866        return None;
867    }
868    let unix_only_prefixes = [
869        "awk ", "sed ", "xargs ", "chmod ", "chown ", "sudo ", "apt ", "apt-get ", "yum ", "dnf ",
870        "brew ", "zsh ", "bash ", "sh ", "uname", "pwd",
871    ];
872    if unix_only_prefixes
873        .iter()
874        .any(|prefix| trimmed.starts_with(prefix))
875    {
876        return Some("unix_command_untranslatable");
877    }
878    if trimmed.contains("/dev/null") || trimmed.contains("~/.") {
879        return Some("posix_path_pattern");
880    }
881    None
882}
883
884struct ReadTool;
885#[async_trait]
886impl Tool for ReadTool {
887    fn schema(&self) -> ToolSchema {
888        ToolSchema {
889            name: "read".to_string(),
890            description: "Read file contents. Supports text files and documents (PDF, DOCX, PPTX, XLSX, RTF).".to_string(),
891            input_schema: json!({
892                "type": "object",
893                "properties": {
894                    "path": {
895                        "type": "string",
896                        "description": "Path to file"
897                    },
898                    "max_size": {
899                        "type": "integer",
900                        "description": "Max file size in bytes (default: 25MB)"
901                    },
902                    "max_chars": {
903                        "type": "integer",
904                        "description": "Max output characters (default: 200,000)"
905                    }
906                },
907                "required": ["path"]
908            }),
909        }
910    }
911    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
912        let path = args["path"].as_str().unwrap_or("");
913        let Some(path_buf) = resolve_tool_path(path, &args) else {
914            return Ok(ToolResult {
915                output: "path denied by sandbox policy".to_string(),
916                metadata: json!({"path": path}),
917            });
918        };
919
920        // Check if it's a document format
921        if is_document_file(&path_buf) {
922            // Use document extraction
923            let mut limits = tandem_document::ExtractLimits::default();
924
925            if let Some(max_size) = args["max_size"].as_u64() {
926                limits.max_file_bytes = max_size;
927            }
928            if let Some(max_chars) = args["max_chars"].as_u64() {
929                limits.max_output_chars = max_chars as usize;
930            }
931
932            match tandem_document::extract_file_text(&path_buf, limits) {
933                Ok(text) => {
934                    let ext = path_buf
935                        .extension()
936                        .and_then(|e| e.to_str())
937                        .unwrap_or("unknown")
938                        .to_lowercase();
939                    return Ok(ToolResult {
940                        output: text,
941                        metadata: json!({
942                            "path": path,
943                            "type": "document",
944                            "format": ext
945                        }),
946                    });
947                }
948                Err(e) => {
949                    return Ok(ToolResult {
950                        output: format!("Failed to extract document text: {}", e),
951                        metadata: json!({"path": path, "error": true}),
952                    });
953                }
954            }
955        }
956
957        // Fallback to text reading
958        let data = fs::read_to_string(&path_buf).await.unwrap_or_default();
959        Ok(ToolResult {
960            output: data,
961            metadata: json!({"path": path_buf.to_string_lossy(), "type": "text"}),
962        })
963    }
964}
965
966struct WriteTool;
967#[async_trait]
968impl Tool for WriteTool {
969    fn schema(&self) -> ToolSchema {
970        ToolSchema {
971            name: "write".to_string(),
972            description: "Write file contents".to_string(),
973            input_schema: json!({
974                "type":"object",
975                "properties":{
976                    "path":{"type":"string"},
977                    "content":{"type":"string"},
978                    "allow_empty":{"type":"boolean"}
979                },
980                "required":["path", "content"]
981            }),
982        }
983    }
984    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
985        let path = args["path"].as_str().unwrap_or("").trim();
986        let content = args["content"].as_str();
987        let allow_empty = args
988            .get("allow_empty")
989            .and_then(|v| v.as_bool())
990            .unwrap_or(false);
991        let Some(path_buf) = resolve_tool_path(path, &args) else {
992            return Ok(ToolResult {
993                output: "path denied by sandbox policy".to_string(),
994                metadata: json!({"path": path}),
995            });
996        };
997        let Some(content) = content else {
998            return Ok(ToolResult {
999                output: "write requires `content`".to_string(),
1000                metadata: json!({"ok": false, "reason": "missing_content", "path": path}),
1001            });
1002        };
1003        if content.is_empty() && !allow_empty {
1004            return Ok(ToolResult {
1005                output: "write requires non-empty `content` (or set allow_empty=true)".to_string(),
1006                metadata: json!({"ok": false, "reason": "empty_content", "path": path}),
1007            });
1008        }
1009        if let Some(parent) = path_buf.parent() {
1010            if !parent.as_os_str().is_empty() {
1011                fs::create_dir_all(parent).await?;
1012            }
1013        }
1014        fs::write(&path_buf, content).await?;
1015        Ok(ToolResult {
1016            output: "ok".to_string(),
1017            metadata: json!({"path": path_buf.to_string_lossy()}),
1018        })
1019    }
1020}
1021
1022struct EditTool;
1023#[async_trait]
1024impl Tool for EditTool {
1025    fn schema(&self) -> ToolSchema {
1026        ToolSchema {
1027            name: "edit".to_string(),
1028            description: "String replacement edit".to_string(),
1029            input_schema: json!({
1030                "type":"object",
1031                "properties":{
1032                    "path":{"type":"string"},
1033                    "old":{"type":"string"},
1034                    "new":{"type":"string"}
1035                },
1036                "required":["path", "old", "new"]
1037            }),
1038        }
1039    }
1040    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1041        let path = args["path"].as_str().unwrap_or("");
1042        let old = args["old"].as_str().unwrap_or("");
1043        let new = args["new"].as_str().unwrap_or("");
1044        let Some(path_buf) = resolve_tool_path(path, &args) else {
1045            return Ok(ToolResult {
1046                output: "path denied by sandbox policy".to_string(),
1047                metadata: json!({"path": path}),
1048            });
1049        };
1050        let content = fs::read_to_string(&path_buf).await.unwrap_or_default();
1051        let updated = content.replace(old, new);
1052        fs::write(&path_buf, updated).await?;
1053        Ok(ToolResult {
1054            output: "ok".to_string(),
1055            metadata: json!({"path": path_buf.to_string_lossy()}),
1056        })
1057    }
1058}
1059
1060struct GlobTool;
1061#[async_trait]
1062impl Tool for GlobTool {
1063    fn schema(&self) -> ToolSchema {
1064        ToolSchema {
1065            name: "glob".to_string(),
1066            description: "Find files by glob".to_string(),
1067            input_schema: json!({"type":"object","properties":{"pattern":{"type":"string"}}}),
1068        }
1069    }
1070    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1071        let pattern = args["pattern"].as_str().unwrap_or("*");
1072        if pattern.contains("..") {
1073            return Ok(ToolResult {
1074                output: "pattern denied by sandbox policy".to_string(),
1075                metadata: json!({"pattern": pattern}),
1076            });
1077        }
1078        if is_malformed_tool_path_token(pattern) {
1079            return Ok(ToolResult {
1080                output: "pattern denied by sandbox policy".to_string(),
1081                metadata: json!({"pattern": pattern}),
1082            });
1083        }
1084        let workspace_root = workspace_root_from_args(&args);
1085        let effective_cwd = effective_cwd_from_args(&args);
1086        let scoped_pattern = if Path::new(pattern).is_absolute() {
1087            pattern.to_string()
1088        } else {
1089            effective_cwd.join(pattern).to_string_lossy().to_string()
1090        };
1091        let mut files = Vec::new();
1092        for path in (glob::glob(&scoped_pattern)?).flatten() {
1093            if is_discovery_ignored_path(&path) {
1094                continue;
1095            }
1096            if let Some(root) = workspace_root.as_ref() {
1097                if !is_within_workspace_root(&path, root) {
1098                    continue;
1099                }
1100            }
1101            files.push(path.display().to_string());
1102            if files.len() >= 100 {
1103                break;
1104            }
1105        }
1106        Ok(ToolResult {
1107            output: files.join("\n"),
1108            metadata: json!({"count": files.len(), "effective_cwd": effective_cwd, "workspace_root": workspace_root}),
1109        })
1110    }
1111}
1112
1113fn is_discovery_ignored_path(path: &Path) -> bool {
1114    path.components()
1115        .any(|component| component.as_os_str() == ".tandem")
1116}
1117
1118struct GrepTool;
1119#[async_trait]
1120impl Tool for GrepTool {
1121    fn schema(&self) -> ToolSchema {
1122        ToolSchema {
1123            name: "grep".to_string(),
1124            description: "Regex search in files".to_string(),
1125            input_schema: json!({"type":"object","properties":{"pattern":{"type":"string"},"path":{"type":"string"}}}),
1126        }
1127    }
1128    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1129        let pattern = args["pattern"].as_str().unwrap_or("");
1130        let root = args["path"].as_str().unwrap_or(".");
1131        let Some(root_path) = resolve_walk_root(root, &args) else {
1132            return Ok(ToolResult {
1133                output: "path denied by sandbox policy".to_string(),
1134                metadata: json!({"path": root}),
1135            });
1136        };
1137        let regex = Regex::new(pattern)?;
1138        let mut out = Vec::new();
1139        for entry in WalkBuilder::new(&root_path).build().flatten() {
1140            if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
1141                continue;
1142            }
1143            let path = entry.path();
1144            if is_discovery_ignored_path(path) {
1145                continue;
1146            }
1147            if let Ok(content) = fs::read_to_string(path).await {
1148                for (idx, line) in content.lines().enumerate() {
1149                    if regex.is_match(line) {
1150                        out.push(format!("{}:{}:{}", path.display(), idx + 1, line));
1151                        if out.len() >= 100 {
1152                            break;
1153                        }
1154                    }
1155                }
1156            }
1157            if out.len() >= 100 {
1158                break;
1159            }
1160        }
1161        Ok(ToolResult {
1162            output: out.join("\n"),
1163            metadata: json!({"count": out.len(), "path": root_path.to_string_lossy()}),
1164        })
1165    }
1166}
1167
1168struct WebFetchTool;
1169#[async_trait]
1170impl Tool for WebFetchTool {
1171    fn schema(&self) -> ToolSchema {
1172        ToolSchema {
1173            name: "webfetch".to_string(),
1174            description: "Fetch URL content and return a structured markdown document".to_string(),
1175            input_schema: json!({
1176                "type":"object",
1177                "properties":{
1178                    "url":{"type":"string"},
1179                    "mode":{"type":"string"},
1180                    "return":{"type":"string"},
1181                    "max_bytes":{"type":"integer"},
1182                    "timeout_ms":{"type":"integer"},
1183                    "max_redirects":{"type":"integer"}
1184                }
1185            }),
1186        }
1187    }
1188    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1189        let url = args["url"].as_str().unwrap_or("").trim();
1190        if url.is_empty() {
1191            return Ok(ToolResult {
1192                output: "url is required".to_string(),
1193                metadata: json!({"url": url}),
1194            });
1195        }
1196        let mode = args["mode"].as_str().unwrap_or("auto");
1197        let return_mode = args["return"].as_str().unwrap_or("markdown");
1198        let timeout_ms = args["timeout_ms"]
1199            .as_u64()
1200            .unwrap_or(15_000)
1201            .clamp(1_000, 120_000);
1202        let max_bytes = args["max_bytes"].as_u64().unwrap_or(500_000).min(5_000_000) as usize;
1203        let max_redirects = args["max_redirects"].as_u64().unwrap_or(5).min(20) as usize;
1204
1205        let started = std::time::Instant::now();
1206        let fetched = fetch_url_with_limits(url, timeout_ms, max_bytes, max_redirects).await?;
1207        let raw = String::from_utf8_lossy(&fetched.buffer).to_string();
1208
1209        let cleaned = strip_html_noise(&raw);
1210        let title = extract_title(&cleaned).unwrap_or_default();
1211        let canonical = extract_canonical(&cleaned);
1212        let links = extract_links(&cleaned);
1213
1214        let markdown = if fetched.content_type.contains("html") || fetched.content_type.is_empty() {
1215            html2md::parse_html(&cleaned)
1216        } else {
1217            cleaned.clone()
1218        };
1219        let text = markdown_to_text(&markdown);
1220
1221        let markdown_out = if return_mode == "text" {
1222            String::new()
1223        } else {
1224            markdown
1225        };
1226        let text_out = if return_mode == "markdown" {
1227            String::new()
1228        } else {
1229            text
1230        };
1231
1232        let raw_chars = raw.chars().count();
1233        let markdown_chars = markdown_out.chars().count();
1234        let reduction_pct = if raw_chars == 0 {
1235            0.0
1236        } else {
1237            ((raw_chars.saturating_sub(markdown_chars)) as f64 / raw_chars as f64) * 100.0
1238        };
1239
1240        let output = json!({
1241            "url": url,
1242            "final_url": fetched.final_url,
1243            "title": title,
1244            "content_type": fetched.content_type,
1245            "markdown": markdown_out,
1246            "text": text_out,
1247            "links": links,
1248            "meta": {
1249                "canonical": canonical,
1250                "mode": mode
1251            },
1252            "stats": {
1253                "bytes_in": fetched.buffer.len(),
1254                "bytes_out": markdown_chars,
1255                "raw_chars": raw_chars,
1256                "markdown_chars": markdown_chars,
1257                "reduction_pct": reduction_pct,
1258                "elapsed_ms": started.elapsed().as_millis(),
1259                "truncated": fetched.truncated
1260            }
1261        });
1262
1263        Ok(ToolResult {
1264            output: serde_json::to_string_pretty(&output)?,
1265            metadata: json!({
1266                "url": url,
1267                "final_url": fetched.final_url,
1268                "content_type": fetched.content_type,
1269                "truncated": fetched.truncated
1270            }),
1271        })
1272    }
1273}
1274
1275struct WebFetchHtmlTool;
1276#[async_trait]
1277impl Tool for WebFetchHtmlTool {
1278    fn schema(&self) -> ToolSchema {
1279        ToolSchema {
1280            name: "webfetch_html".to_string(),
1281            description: "Fetch URL and return raw HTML content".to_string(),
1282            input_schema: json!({
1283                "type":"object",
1284                "properties":{
1285                    "url":{"type":"string"},
1286                    "max_bytes":{"type":"integer"},
1287                    "timeout_ms":{"type":"integer"},
1288                    "max_redirects":{"type":"integer"}
1289                }
1290            }),
1291        }
1292    }
1293    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1294        let url = args["url"].as_str().unwrap_or("").trim();
1295        if url.is_empty() {
1296            return Ok(ToolResult {
1297                output: "url is required".to_string(),
1298                metadata: json!({"url": url}),
1299            });
1300        }
1301        let timeout_ms = args["timeout_ms"]
1302            .as_u64()
1303            .unwrap_or(15_000)
1304            .clamp(1_000, 120_000);
1305        let max_bytes = args["max_bytes"].as_u64().unwrap_or(500_000).min(5_000_000) as usize;
1306        let max_redirects = args["max_redirects"].as_u64().unwrap_or(5).min(20) as usize;
1307
1308        let started = std::time::Instant::now();
1309        let fetched = fetch_url_with_limits(url, timeout_ms, max_bytes, max_redirects).await?;
1310        let output = String::from_utf8_lossy(&fetched.buffer).to_string();
1311
1312        Ok(ToolResult {
1313            output,
1314            metadata: json!({
1315                "url": url,
1316                "final_url": fetched.final_url,
1317                "content_type": fetched.content_type,
1318                "truncated": fetched.truncated,
1319                "bytes_in": fetched.buffer.len(),
1320                "elapsed_ms": started.elapsed().as_millis()
1321            }),
1322        })
1323    }
1324}
1325
1326struct FetchedResponse {
1327    final_url: String,
1328    content_type: String,
1329    buffer: Vec<u8>,
1330    truncated: bool,
1331}
1332
1333async fn fetch_url_with_limits(
1334    url: &str,
1335    timeout_ms: u64,
1336    max_bytes: usize,
1337    max_redirects: usize,
1338) -> anyhow::Result<FetchedResponse> {
1339    let client = reqwest::Client::builder()
1340        .timeout(std::time::Duration::from_millis(timeout_ms))
1341        .redirect(reqwest::redirect::Policy::limited(max_redirects))
1342        .build()?;
1343
1344    let res = client
1345        .get(url)
1346        .header(
1347            "Accept",
1348            "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
1349        )
1350        .send()
1351        .await?;
1352    let final_url = res.url().to_string();
1353    let content_type = res
1354        .headers()
1355        .get("content-type")
1356        .and_then(|v| v.to_str().ok())
1357        .unwrap_or("")
1358        .to_string();
1359
1360    let mut stream = res.bytes_stream();
1361    let mut buffer: Vec<u8> = Vec::new();
1362    let mut truncated = false;
1363    while let Some(chunk) = stream.next().await {
1364        let chunk = chunk?;
1365        if buffer.len() + chunk.len() > max_bytes {
1366            let remaining = max_bytes.saturating_sub(buffer.len());
1367            buffer.extend_from_slice(&chunk[..remaining]);
1368            truncated = true;
1369            break;
1370        }
1371        buffer.extend_from_slice(&chunk);
1372    }
1373
1374    Ok(FetchedResponse {
1375        final_url,
1376        content_type,
1377        buffer,
1378        truncated,
1379    })
1380}
1381
1382fn strip_html_noise(input: &str) -> String {
1383    let script_re = Regex::new(r"(?is)<script[^>]*>.*?</script>").unwrap();
1384    let style_re = Regex::new(r"(?is)<style[^>]*>.*?</style>").unwrap();
1385    let noscript_re = Regex::new(r"(?is)<noscript[^>]*>.*?</noscript>").unwrap();
1386    let cleaned = script_re.replace_all(input, "");
1387    let cleaned = style_re.replace_all(&cleaned, "");
1388    let cleaned = noscript_re.replace_all(&cleaned, "");
1389    cleaned.to_string()
1390}
1391
1392fn extract_title(input: &str) -> Option<String> {
1393    let title_re = Regex::new(r"(?is)<title[^>]*>(.*?)</title>").ok()?;
1394    let caps = title_re.captures(input)?;
1395    let raw = caps.get(1)?.as_str();
1396    let tag_re = Regex::new(r"(?is)<[^>]+>").ok()?;
1397    Some(tag_re.replace_all(raw, "").trim().to_string())
1398}
1399
1400fn extract_canonical(input: &str) -> Option<String> {
1401    let canon_re =
1402        Regex::new(r#"(?is)<link[^>]*rel=["']canonical["'][^>]*href=["']([^"']+)["'][^>]*>"#)
1403            .ok()?;
1404    let caps = canon_re.captures(input)?;
1405    Some(caps.get(1)?.as_str().trim().to_string())
1406}
1407
1408fn extract_links(input: &str) -> Vec<Value> {
1409    let link_re = Regex::new(r#"(?is)<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>"#).unwrap();
1410    let tag_re = Regex::new(r"(?is)<[^>]+>").unwrap();
1411    let mut out = Vec::new();
1412    for caps in link_re.captures_iter(input).take(200) {
1413        let href = caps.get(1).map(|m| m.as_str()).unwrap_or("").trim();
1414        let raw_text = caps.get(2).map(|m| m.as_str()).unwrap_or("");
1415        let text = tag_re.replace_all(raw_text, "");
1416        if !href.is_empty() {
1417            out.push(json!({
1418                "text": text.trim(),
1419                "href": href
1420            }));
1421        }
1422    }
1423    out
1424}
1425
1426fn markdown_to_text(input: &str) -> String {
1427    let code_block_re = Regex::new(r"(?s)```.*?```").unwrap();
1428    let inline_code_re = Regex::new(r"`[^`]*`").unwrap();
1429    let link_re = Regex::new(r"\[([^\]]+)\]\([^)]+\)").unwrap();
1430    let emphasis_re = Regex::new(r"[*_~]+").unwrap();
1431    let cleaned = code_block_re.replace_all(input, "");
1432    let cleaned = inline_code_re.replace_all(&cleaned, "");
1433    let cleaned = link_re.replace_all(&cleaned, "$1");
1434    let cleaned = emphasis_re.replace_all(&cleaned, "");
1435    let cleaned = cleaned.replace('#', "");
1436    let whitespace_re = Regex::new(r"\n{3,}").unwrap();
1437    let cleaned = whitespace_re.replace_all(&cleaned, "\n\n");
1438    cleaned.trim().to_string()
1439}
1440
1441struct McpDebugTool;
1442#[async_trait]
1443impl Tool for McpDebugTool {
1444    fn schema(&self) -> ToolSchema {
1445        ToolSchema {
1446            name: "mcp_debug".to_string(),
1447            description: "Call an MCP tool and return the raw response".to_string(),
1448            input_schema: json!({
1449                "type":"object",
1450                "properties":{
1451                    "url":{"type":"string"},
1452                    "tool":{"type":"string"},
1453                    "args":{"type":"object"},
1454                    "headers":{"type":"object"},
1455                    "timeout_ms":{"type":"integer"},
1456                    "max_bytes":{"type":"integer"}
1457                }
1458            }),
1459        }
1460    }
1461    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1462        let url = args["url"].as_str().unwrap_or("").trim();
1463        let tool = args["tool"].as_str().unwrap_or("").trim();
1464        if url.is_empty() || tool.is_empty() {
1465            return Ok(ToolResult {
1466                output: "url and tool are required".to_string(),
1467                metadata: json!({"url": url, "tool": tool}),
1468            });
1469        }
1470        let timeout_ms = args["timeout_ms"]
1471            .as_u64()
1472            .unwrap_or(15_000)
1473            .clamp(1_000, 120_000);
1474        let max_bytes = args["max_bytes"].as_u64().unwrap_or(200_000).min(5_000_000) as usize;
1475        let request_args = args.get("args").cloned().unwrap_or_else(|| json!({}));
1476
1477        #[derive(serde::Serialize)]
1478        struct McpCallRequest {
1479            jsonrpc: String,
1480            id: u32,
1481            method: String,
1482            params: McpCallParams,
1483        }
1484
1485        #[derive(serde::Serialize)]
1486        struct McpCallParams {
1487            name: String,
1488            arguments: Value,
1489        }
1490
1491        let request = McpCallRequest {
1492            jsonrpc: "2.0".to_string(),
1493            id: 1,
1494            method: "tools/call".to_string(),
1495            params: McpCallParams {
1496                name: tool.to_string(),
1497                arguments: request_args,
1498            },
1499        };
1500
1501        let client = reqwest::Client::builder()
1502            .timeout(std::time::Duration::from_millis(timeout_ms))
1503            .build()?;
1504
1505        let mut builder = client
1506            .post(url)
1507            .header("Content-Type", "application/json")
1508            .header("Accept", "application/json, text/event-stream");
1509
1510        if let Some(headers) = args.get("headers").and_then(|v| v.as_object()) {
1511            for (key, value) in headers {
1512                if let Some(value) = value.as_str() {
1513                    builder = builder.header(key, value);
1514                }
1515            }
1516        }
1517
1518        let res = builder.json(&request).send().await?;
1519        let status = res.status().as_u16();
1520
1521        let mut response_headers = serde_json::Map::new();
1522        for (key, value) in res.headers().iter() {
1523            if let Ok(value) = value.to_str() {
1524                response_headers.insert(key.to_string(), Value::String(value.to_string()));
1525            }
1526        }
1527
1528        let mut stream = res.bytes_stream();
1529        let mut buffer: Vec<u8> = Vec::new();
1530        let mut truncated = false;
1531
1532        while let Some(chunk) = stream.next().await {
1533            let chunk = chunk?;
1534            if buffer.len() + chunk.len() > max_bytes {
1535                let remaining = max_bytes.saturating_sub(buffer.len());
1536                buffer.extend_from_slice(&chunk[..remaining]);
1537                truncated = true;
1538                break;
1539            }
1540            buffer.extend_from_slice(&chunk);
1541        }
1542
1543        let body = String::from_utf8_lossy(&buffer).to_string();
1544        let output = json!({
1545            "status": status,
1546            "headers": response_headers,
1547            "body": body,
1548            "truncated": truncated,
1549            "bytes": buffer.len()
1550        });
1551
1552        Ok(ToolResult {
1553            output: serde_json::to_string_pretty(&output)?,
1554            metadata: json!({
1555                "url": url,
1556                "tool": tool,
1557                "timeout_ms": timeout_ms,
1558                "max_bytes": max_bytes
1559            }),
1560        })
1561    }
1562}
1563
1564struct WebSearchTool;
1565#[async_trait]
1566impl Tool for WebSearchTool {
1567    fn schema(&self) -> ToolSchema {
1568        ToolSchema {
1569            name: "websearch".to_string(),
1570            description: "Search web results using Exa.ai MCP endpoint".to_string(),
1571            input_schema: json!({
1572                "type": "object",
1573                "properties": {
1574                    "query": { "type": "string" },
1575                    "limit": { "type": "integer" }
1576                },
1577                "required": ["query"]
1578            }),
1579        }
1580    }
1581    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1582        let query = extract_websearch_query(&args).unwrap_or_default();
1583        let query_source = args
1584            .get("__query_source")
1585            .and_then(|v| v.as_str())
1586            .map(|s| s.to_string())
1587            .unwrap_or_else(|| {
1588                if query.is_empty() {
1589                    "missing".to_string()
1590                } else {
1591                    "tool_args".to_string()
1592                }
1593            });
1594        let query_hash = if query.is_empty() {
1595            None
1596        } else {
1597            Some(stable_hash(&query))
1598        };
1599        if query.is_empty() {
1600            tracing::warn!("WebSearchTool missing query. Args: {}", args);
1601            return Ok(ToolResult {
1602                output: format!("missing query. Received args: {}", args),
1603                metadata: json!({
1604                    "count": 0,
1605                    "error": "missing_query",
1606                    "query_source": query_source,
1607                    "query_hash": query_hash,
1608                    "loop_guard_triggered": false
1609                }),
1610            });
1611        }
1612        let num_results = extract_websearch_limit(&args).unwrap_or(8);
1613
1614        #[derive(serde::Serialize)]
1615        struct McpSearchRequest {
1616            jsonrpc: String,
1617            id: u32,
1618            method: String,
1619            params: McpSearchParams,
1620        }
1621
1622        #[derive(serde::Serialize)]
1623        struct McpSearchParams {
1624            name: String,
1625            arguments: McpSearchArgs,
1626        }
1627
1628        #[derive(serde::Serialize)]
1629        struct McpSearchArgs {
1630            query: String,
1631            #[serde(rename = "numResults")]
1632            num_results: u64,
1633        }
1634
1635        let request = McpSearchRequest {
1636            jsonrpc: "2.0".to_string(),
1637            id: 1,
1638            method: "tools/call".to_string(),
1639            params: McpSearchParams {
1640                name: "web_search_exa".to_string(),
1641                arguments: McpSearchArgs {
1642                    query: query.to_string(),
1643                    num_results,
1644                },
1645            },
1646        };
1647
1648        let client = reqwest::Client::new();
1649        let res = client
1650            .post("https://mcp.exa.ai/mcp")
1651            .header("Content-Type", "application/json")
1652            .header("Accept", "application/json, text/event-stream")
1653            .json(&request)
1654            .send()
1655            .await?;
1656
1657        if !res.status().is_success() {
1658            let error_text = res.text().await?;
1659            return Err(anyhow::anyhow!("Search error: {}", error_text));
1660        }
1661
1662        let mut stream = res.bytes_stream();
1663        let mut buffer = Vec::new();
1664        let timeout_duration = std::time::Duration::from_secs(10); // Wait at most 10s for first chunk
1665
1666        // We use a loop but breaks on first result.
1667        // We also want to apply a timeout to receiving ANY chunk from the stream.
1668        loop {
1669            let chunk_future = stream.next();
1670            match tokio::time::timeout(timeout_duration, chunk_future).await {
1671                Ok(Some(chunk_result)) => {
1672                    let chunk = chunk_result?;
1673                    tracing::info!("WebSearchTool received chunk size: {}", chunk.len());
1674                    buffer.extend_from_slice(&chunk);
1675
1676                    while let Some(idx) = buffer.iter().position(|&b| b == b'\n') {
1677                        let line_bytes: Vec<u8> = buffer.drain(..=idx).collect();
1678                        let line = String::from_utf8_lossy(&line_bytes);
1679                        let line = line.trim();
1680                        tracing::info!("WebSearchTool parsing line: {}", line);
1681
1682                        if let Some(data) = line.strip_prefix("data: ") {
1683                            if let Ok(val) = serde_json::from_str::<Value>(data.trim()) {
1684                                if let Some(content) = val
1685                                    .get("result")
1686                                    .and_then(|r| r.get("content"))
1687                                    .and_then(|c| c.as_array())
1688                                {
1689                                    if let Some(first) = content.first() {
1690                                        if let Some(text) =
1691                                            first.get("text").and_then(|t| t.as_str())
1692                                        {
1693                                            return Ok(ToolResult {
1694                                                output: text.to_string(),
1695                                                metadata: json!({
1696                                                    "query": query,
1697                                                    "query_source": query_source,
1698                                                    "query_hash": query_hash,
1699                                                    "loop_guard_triggered": false
1700                                                }),
1701                                            });
1702                                        }
1703                                    }
1704                                }
1705                            }
1706                        }
1707                    }
1708                }
1709                Ok(None) => {
1710                    tracing::info!("WebSearchTool stream ended without result.");
1711                    break;
1712                }
1713                Err(_) => {
1714                    tracing::warn!("WebSearchTool stream timed out waiting for chunk.");
1715                    return Ok(ToolResult {
1716                        output: "Search timed out. No results received.".to_string(),
1717                        metadata: json!({
1718                            "query": query,
1719                            "error": "timeout",
1720                            "query_source": query_source,
1721                            "query_hash": query_hash,
1722                            "loop_guard_triggered": false
1723                        }),
1724                    });
1725                }
1726            }
1727        }
1728
1729        Ok(ToolResult {
1730            output: "No search results found.".to_string(),
1731            metadata: json!({
1732                "query": query,
1733                "query_source": query_source,
1734                "query_hash": query_hash,
1735                "loop_guard_triggered": false
1736            }),
1737        })
1738    }
1739}
1740
1741fn stable_hash(input: &str) -> String {
1742    let mut hasher = DefaultHasher::new();
1743    input.hash(&mut hasher);
1744    format!("{:016x}", hasher.finish())
1745}
1746
1747fn extract_websearch_query(args: &Value) -> Option<String> {
1748    // Direct keys first.
1749    const QUERY_KEYS: [&str; 5] = ["query", "q", "search_query", "searchQuery", "keywords"];
1750    for key in QUERY_KEYS {
1751        if let Some(query) = args.get(key).and_then(|v| v.as_str()) {
1752            let trimmed = query.trim();
1753            if !trimmed.is_empty() {
1754                return Some(trimmed.to_string());
1755            }
1756        }
1757    }
1758
1759    // Some tool-call envelopes nest args.
1760    for container in ["arguments", "args", "input", "params"] {
1761        if let Some(obj) = args.get(container) {
1762            for key in QUERY_KEYS {
1763                if let Some(query) = obj.get(key).and_then(|v| v.as_str()) {
1764                    let trimmed = query.trim();
1765                    if !trimmed.is_empty() {
1766                        return Some(trimmed.to_string());
1767                    }
1768                }
1769            }
1770        }
1771    }
1772
1773    // Last resort: plain string args.
1774    args.as_str()
1775        .map(str::trim)
1776        .filter(|s| !s.is_empty())
1777        .map(ToString::to_string)
1778}
1779
1780fn extract_websearch_limit(args: &Value) -> Option<u64> {
1781    let mut read_limit = |value: &Value| value.as_u64().map(|v| v.clamp(1, 10));
1782
1783    if let Some(limit) = args
1784        .get("limit")
1785        .and_then(&mut read_limit)
1786        .or_else(|| args.get("numResults").and_then(&mut read_limit))
1787        .or_else(|| args.get("num_results").and_then(&mut read_limit))
1788    {
1789        return Some(limit);
1790    }
1791
1792    for container in ["arguments", "args", "input", "params"] {
1793        if let Some(obj) = args.get(container) {
1794            if let Some(limit) = obj
1795                .get("limit")
1796                .and_then(&mut read_limit)
1797                .or_else(|| obj.get("numResults").and_then(&mut read_limit))
1798                .or_else(|| obj.get("num_results").and_then(&mut read_limit))
1799            {
1800                return Some(limit);
1801            }
1802        }
1803    }
1804    None
1805}
1806
1807struct CodeSearchTool;
1808#[async_trait]
1809impl Tool for CodeSearchTool {
1810    fn schema(&self) -> ToolSchema {
1811        ToolSchema {
1812            name: "codesearch".to_string(),
1813            description: "Search code in workspace files".to_string(),
1814            input_schema: json!({"type":"object","properties":{"query":{"type":"string"},"path":{"type":"string"},"limit":{"type":"integer"}}}),
1815        }
1816    }
1817    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1818        let query = args["query"].as_str().unwrap_or("").trim();
1819        if query.is_empty() {
1820            return Ok(ToolResult {
1821                output: "missing query".to_string(),
1822                metadata: json!({"count": 0}),
1823            });
1824        }
1825        let root = args["path"].as_str().unwrap_or(".");
1826        let Some(root_path) = resolve_walk_root(root, &args) else {
1827            return Ok(ToolResult {
1828                output: "path denied by sandbox policy".to_string(),
1829                metadata: json!({"path": root}),
1830            });
1831        };
1832        let limit = args["limit"]
1833            .as_u64()
1834            .map(|v| v.clamp(1, 200) as usize)
1835            .unwrap_or(50);
1836        let mut hits = Vec::new();
1837        let lower = query.to_lowercase();
1838        for entry in WalkBuilder::new(&root_path).build().flatten() {
1839            if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
1840                continue;
1841            }
1842            let path = entry.path();
1843            let ext = path.extension().and_then(|v| v.to_str()).unwrap_or("");
1844            if !matches!(
1845                ext,
1846                "rs" | "ts" | "tsx" | "js" | "jsx" | "py" | "md" | "toml" | "json"
1847            ) {
1848                continue;
1849            }
1850            if let Ok(content) = fs::read_to_string(path).await {
1851                for (idx, line) in content.lines().enumerate() {
1852                    if line.to_lowercase().contains(&lower) {
1853                        hits.push(format!("{}:{}:{}", path.display(), idx + 1, line.trim()));
1854                        if hits.len() >= limit {
1855                            break;
1856                        }
1857                    }
1858                }
1859            }
1860            if hits.len() >= limit {
1861                break;
1862            }
1863        }
1864        Ok(ToolResult {
1865            output: hits.join("\n"),
1866            metadata: json!({"count": hits.len(), "query": query, "path": root_path.to_string_lossy()}),
1867        })
1868    }
1869}
1870
1871struct TodoWriteTool;
1872#[async_trait]
1873impl Tool for TodoWriteTool {
1874    fn schema(&self) -> ToolSchema {
1875        ToolSchema {
1876            name: "todo_write".to_string(),
1877            description: "Update todo list".to_string(),
1878            input_schema: json!({
1879                "type":"object",
1880                "properties":{
1881                    "todos":{
1882                        "type":"array",
1883                        "items":{
1884                            "type":"object",
1885                            "properties":{
1886                                "id":{"type":"string"},
1887                                "content":{"type":"string"},
1888                                "text":{"type":"string"},
1889                                "status":{"type":"string"}
1890                            }
1891                        }
1892                    }
1893                }
1894            }),
1895        }
1896    }
1897    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1898        let todos = normalize_todos(args["todos"].as_array().cloned().unwrap_or_default());
1899        Ok(ToolResult {
1900            output: format!("todo list updated: {} items", todos.len()),
1901            metadata: json!({"todos": todos}),
1902        })
1903    }
1904}
1905
1906struct TaskTool;
1907#[async_trait]
1908impl Tool for TaskTool {
1909    fn schema(&self) -> ToolSchema {
1910        ToolSchema {
1911            name: "task".to_string(),
1912            description: "Create a subtask summary for orchestrator".to_string(),
1913            input_schema: json!({"type":"object","properties":{"description":{"type":"string"},"prompt":{"type":"string"}}}),
1914        }
1915    }
1916    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1917        let description = args["description"].as_str().unwrap_or("subtask");
1918        Ok(ToolResult {
1919            output: format!("Subtask planned: {description}"),
1920            metadata: json!({"description": description, "prompt": args["prompt"]}),
1921        })
1922    }
1923}
1924
1925struct QuestionTool;
1926#[async_trait]
1927impl Tool for QuestionTool {
1928    fn schema(&self) -> ToolSchema {
1929        ToolSchema {
1930            name: "question".to_string(),
1931            description: "Emit a question request for the user".to_string(),
1932            input_schema: json!({
1933                "type":"object",
1934                "properties":{
1935                    "questions":{
1936                        "type":"array",
1937                        "items":{
1938                            "type":"object",
1939                            "properties":{
1940                                "question":{"type":"string"},
1941                                "choices":{"type":"array","items":{"type":"string"}}
1942                            }
1943                        }
1944                    }
1945                }
1946            }),
1947        }
1948    }
1949    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1950        Ok(ToolResult {
1951            output: "Question requested. Use /question endpoints to respond.".to_string(),
1952            metadata: json!({"questions": args["questions"]}),
1953        })
1954    }
1955}
1956
1957struct SpawnAgentTool;
1958#[async_trait]
1959impl Tool for SpawnAgentTool {
1960    fn schema(&self) -> ToolSchema {
1961        ToolSchema {
1962            name: "spawn_agent".to_string(),
1963            description: "Spawn an agent-team instance through server policy enforcement."
1964                .to_string(),
1965            input_schema: json!({
1966                "type":"object",
1967                "properties":{
1968                    "missionID":{"type":"string"},
1969                    "parentInstanceID":{"type":"string"},
1970                    "templateID":{"type":"string"},
1971                    "role":{"type":"string","enum":["orchestrator","delegator","worker","watcher","reviewer","tester","committer"]},
1972                    "source":{"type":"string","enum":["tool_call"]},
1973                    "justification":{"type":"string"},
1974                    "budgetOverride":{"type":"object"}
1975                },
1976                "required":["role","justification"]
1977            }),
1978        }
1979    }
1980
1981    async fn execute(&self, _args: Value) -> anyhow::Result<ToolResult> {
1982        Ok(ToolResult {
1983            output: "spawn_agent must be executed through the engine runtime.".to_string(),
1984            metadata: json!({
1985                "ok": false,
1986                "code": "SPAWN_HOOK_UNAVAILABLE"
1987            }),
1988        })
1989    }
1990}
1991
1992struct MemorySearchTool;
1993#[async_trait]
1994impl Tool for MemorySearchTool {
1995    fn schema(&self) -> ToolSchema {
1996        ToolSchema {
1997            name: "memory_search".to_string(),
1998            description: "Search tandem memory across session/project/global tiers. Global scope is opt-in via allow_global=true (or TANDEM_ENABLE_GLOBAL_MEMORY=1).".to_string(),
1999            input_schema: json!({
2000                "type":"object",
2001                "properties":{
2002                    "query":{"type":"string"},
2003                    "session_id":{"type":"string"},
2004                    "project_id":{"type":"string"},
2005                    "tier":{"type":"string","enum":["session","project","global"]},
2006                    "limit":{"type":"integer","minimum":1,"maximum":20},
2007                    "allow_global":{"type":"boolean"},
2008                    "db_path":{"type":"string"}
2009                },
2010                "required":["query"]
2011            }),
2012        }
2013    }
2014
2015    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
2016        let query = args
2017            .get("query")
2018            .or_else(|| args.get("q"))
2019            .and_then(|v| v.as_str())
2020            .map(str::trim)
2021            .unwrap_or("");
2022        if query.is_empty() {
2023            return Ok(ToolResult {
2024                output: "memory_search requires a non-empty query".to_string(),
2025                metadata: json!({"ok": false, "reason": "missing_query"}),
2026            });
2027        }
2028
2029        let session_id = args
2030            .get("session_id")
2031            .and_then(|v| v.as_str())
2032            .map(str::trim)
2033            .filter(|s| !s.is_empty())
2034            .map(ToString::to_string);
2035        let project_id = args
2036            .get("project_id")
2037            .and_then(|v| v.as_str())
2038            .map(str::trim)
2039            .filter(|s| !s.is_empty())
2040            .map(ToString::to_string);
2041        let allow_global = global_memory_enabled(&args);
2042        if session_id.is_none() && project_id.is_none() && !allow_global {
2043            return Ok(ToolResult {
2044                output: "memory_search requires at least one scope: session_id or project_id (or allow_global=true)"
2045                    .to_string(),
2046                metadata: json!({"ok": false, "reason": "missing_scope"}),
2047            });
2048        }
2049
2050        let tier = match args
2051            .get("tier")
2052            .and_then(|v| v.as_str())
2053            .map(|s| s.trim().to_ascii_lowercase())
2054        {
2055            Some(t) if t == "session" => Some(MemoryTier::Session),
2056            Some(t) if t == "project" => Some(MemoryTier::Project),
2057            Some(t) if t == "global" => Some(MemoryTier::Global),
2058            Some(_) => {
2059                return Ok(ToolResult {
2060                    output: "memory_search tier must be one of: session, project, global"
2061                        .to_string(),
2062                    metadata: json!({"ok": false, "reason": "invalid_tier"}),
2063                });
2064            }
2065            None => None,
2066        };
2067        if matches!(tier, Some(MemoryTier::Session)) && session_id.is_none() {
2068            return Ok(ToolResult {
2069                output: "tier=session requires session_id".to_string(),
2070                metadata: json!({"ok": false, "reason": "missing_session_scope"}),
2071            });
2072        }
2073        if matches!(tier, Some(MemoryTier::Project)) && project_id.is_none() {
2074            return Ok(ToolResult {
2075                output: "tier=project requires project_id".to_string(),
2076                metadata: json!({"ok": false, "reason": "missing_project_scope"}),
2077            });
2078        }
2079        if matches!(tier, Some(MemoryTier::Global)) && !allow_global {
2080            return Ok(ToolResult {
2081                output: "tier=global requires allow_global=true".to_string(),
2082                metadata: json!({"ok": false, "reason": "global_scope_disabled"}),
2083            });
2084        }
2085
2086        let limit = args
2087            .get("limit")
2088            .and_then(|v| v.as_i64())
2089            .unwrap_or(5)
2090            .clamp(1, 20);
2091
2092        let db_path = resolve_memory_db_path(&args);
2093        let db_exists = db_path.exists();
2094        if !db_exists {
2095            return Ok(ToolResult {
2096                output: "memory database not found".to_string(),
2097                metadata: json!({
2098                    "ok": false,
2099                    "reason": "memory_db_missing",
2100                    "db_path": db_path,
2101                }),
2102            });
2103        }
2104
2105        let manager = MemoryManager::new(&db_path).await?;
2106        let health = manager.embedding_health().await;
2107        if health.status != "ok" {
2108            return Ok(ToolResult {
2109                output: "memory embeddings unavailable; semantic search is disabled".to_string(),
2110                metadata: json!({
2111                    "ok": false,
2112                    "reason": "embeddings_unavailable",
2113                    "embedding_status": health.status,
2114                    "embedding_reason": health.reason,
2115                }),
2116            });
2117        }
2118
2119        let mut results: Vec<MemorySearchResult> = Vec::new();
2120        match tier {
2121            Some(MemoryTier::Session) => {
2122                results.extend(
2123                    manager
2124                        .search(
2125                            query,
2126                            Some(MemoryTier::Session),
2127                            project_id.as_deref(),
2128                            session_id.as_deref(),
2129                            Some(limit),
2130                        )
2131                        .await?,
2132                );
2133            }
2134            Some(MemoryTier::Project) => {
2135                results.extend(
2136                    manager
2137                        .search(
2138                            query,
2139                            Some(MemoryTier::Project),
2140                            project_id.as_deref(),
2141                            session_id.as_deref(),
2142                            Some(limit),
2143                        )
2144                        .await?,
2145                );
2146            }
2147            Some(MemoryTier::Global) => {
2148                results.extend(
2149                    manager
2150                        .search(query, Some(MemoryTier::Global), None, None, Some(limit))
2151                        .await?,
2152                );
2153            }
2154            _ => {
2155                if session_id.is_some() {
2156                    results.extend(
2157                        manager
2158                            .search(
2159                                query,
2160                                Some(MemoryTier::Session),
2161                                project_id.as_deref(),
2162                                session_id.as_deref(),
2163                                Some(limit),
2164                            )
2165                            .await?,
2166                    );
2167                }
2168                if project_id.is_some() {
2169                    results.extend(
2170                        manager
2171                            .search(
2172                                query,
2173                                Some(MemoryTier::Project),
2174                                project_id.as_deref(),
2175                                session_id.as_deref(),
2176                                Some(limit),
2177                            )
2178                            .await?,
2179                    );
2180                }
2181                if allow_global {
2182                    results.extend(
2183                        manager
2184                            .search(query, Some(MemoryTier::Global), None, None, Some(limit))
2185                            .await?,
2186                    );
2187                }
2188            }
2189        }
2190
2191        let mut dedup: HashMap<String, MemorySearchResult> = HashMap::new();
2192        for result in results {
2193            match dedup.get(&result.chunk.id) {
2194                Some(existing) if existing.similarity >= result.similarity => {}
2195                _ => {
2196                    dedup.insert(result.chunk.id.clone(), result);
2197                }
2198            }
2199        }
2200        let mut merged = dedup.into_values().collect::<Vec<_>>();
2201        merged.sort_by(|a, b| b.similarity.total_cmp(&a.similarity));
2202        merged.truncate(limit as usize);
2203
2204        let output_rows = merged
2205            .iter()
2206            .map(|item| {
2207                json!({
2208                    "chunk_id": item.chunk.id,
2209                    "tier": item.chunk.tier.to_string(),
2210                    "session_id": item.chunk.session_id,
2211                    "project_id": item.chunk.project_id,
2212                    "source": item.chunk.source,
2213                    "similarity": item.similarity,
2214                    "content": item.chunk.content,
2215                    "created_at": item.chunk.created_at,
2216                })
2217            })
2218            .collect::<Vec<_>>();
2219
2220        Ok(ToolResult {
2221            output: serde_json::to_string_pretty(&output_rows).unwrap_or_default(),
2222            metadata: json!({
2223                "ok": true,
2224                "count": output_rows.len(),
2225                "limit": limit,
2226                "query": query,
2227                "session_id": session_id,
2228                "project_id": project_id,
2229                "allow_global": allow_global,
2230                "embedding_status": health.status,
2231                "embedding_reason": health.reason,
2232                "strict_scope": !allow_global,
2233            }),
2234        })
2235    }
2236}
2237
2238struct MemoryStoreTool;
2239#[async_trait]
2240impl Tool for MemoryStoreTool {
2241    fn schema(&self) -> ToolSchema {
2242        ToolSchema {
2243            name: "memory_store".to_string(),
2244            description: "Store memory chunks in session/project/global tiers. Global writes are opt-in via allow_global=true (or TANDEM_ENABLE_GLOBAL_MEMORY=1).".to_string(),
2245            input_schema: json!({
2246                "type":"object",
2247                "properties":{
2248                    "content":{"type":"string"},
2249                    "tier":{"type":"string","enum":["session","project","global"]},
2250                    "session_id":{"type":"string"},
2251                    "project_id":{"type":"string"},
2252                    "source":{"type":"string"},
2253                    "metadata":{"type":"object"},
2254                    "allow_global":{"type":"boolean"},
2255                    "db_path":{"type":"string"}
2256                },
2257                "required":["content"]
2258            }),
2259        }
2260    }
2261
2262    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
2263        let content = args
2264            .get("content")
2265            .and_then(|v| v.as_str())
2266            .map(str::trim)
2267            .unwrap_or("");
2268        if content.is_empty() {
2269            return Ok(ToolResult {
2270                output: "memory_store requires non-empty content".to_string(),
2271                metadata: json!({"ok": false, "reason": "missing_content"}),
2272            });
2273        }
2274
2275        let session_id = args
2276            .get("session_id")
2277            .and_then(|v| v.as_str())
2278            .map(str::trim)
2279            .filter(|s| !s.is_empty())
2280            .map(ToString::to_string);
2281        let project_id = args
2282            .get("project_id")
2283            .and_then(|v| v.as_str())
2284            .map(str::trim)
2285            .filter(|s| !s.is_empty())
2286            .map(ToString::to_string);
2287        let allow_global = global_memory_enabled(&args);
2288
2289        let tier = match args
2290            .get("tier")
2291            .and_then(|v| v.as_str())
2292            .map(|s| s.trim().to_ascii_lowercase())
2293        {
2294            Some(t) if t == "session" => MemoryTier::Session,
2295            Some(t) if t == "project" => MemoryTier::Project,
2296            Some(t) if t == "global" => MemoryTier::Global,
2297            Some(_) => {
2298                return Ok(ToolResult {
2299                    output: "memory_store tier must be one of: session, project, global"
2300                        .to_string(),
2301                    metadata: json!({"ok": false, "reason": "invalid_tier"}),
2302                });
2303            }
2304            None => {
2305                if project_id.is_some() {
2306                    MemoryTier::Project
2307                } else if session_id.is_some() {
2308                    MemoryTier::Session
2309                } else if allow_global {
2310                    MemoryTier::Global
2311                } else {
2312                    return Ok(ToolResult {
2313                        output: "memory_store requires scope: session_id or project_id (or allow_global=true)"
2314                            .to_string(),
2315                        metadata: json!({"ok": false, "reason": "missing_scope"}),
2316                    });
2317                }
2318            }
2319        };
2320
2321        if matches!(tier, MemoryTier::Session) && session_id.is_none() {
2322            return Ok(ToolResult {
2323                output: "tier=session requires session_id".to_string(),
2324                metadata: json!({"ok": false, "reason": "missing_session_scope"}),
2325            });
2326        }
2327        if matches!(tier, MemoryTier::Project) && project_id.is_none() {
2328            return Ok(ToolResult {
2329                output: "tier=project requires project_id".to_string(),
2330                metadata: json!({"ok": false, "reason": "missing_project_scope"}),
2331            });
2332        }
2333        if matches!(tier, MemoryTier::Global) && !allow_global {
2334            return Ok(ToolResult {
2335                output: "tier=global requires allow_global=true".to_string(),
2336                metadata: json!({"ok": false, "reason": "global_scope_disabled"}),
2337            });
2338        }
2339
2340        let db_path = resolve_memory_db_path(&args);
2341        let manager = MemoryManager::new(&db_path).await?;
2342        let health = manager.embedding_health().await;
2343        if health.status != "ok" {
2344            return Ok(ToolResult {
2345                output: "memory embeddings unavailable; semantic memory store is disabled"
2346                    .to_string(),
2347                metadata: json!({
2348                    "ok": false,
2349                    "reason": "embeddings_unavailable",
2350                    "embedding_status": health.status,
2351                    "embedding_reason": health.reason,
2352                }),
2353            });
2354        }
2355
2356        let source = args
2357            .get("source")
2358            .and_then(|v| v.as_str())
2359            .map(str::trim)
2360            .filter(|s| !s.is_empty())
2361            .unwrap_or("agent_note")
2362            .to_string();
2363        let metadata = args.get("metadata").cloned();
2364
2365        let request = tandem_memory::types::StoreMessageRequest {
2366            content: content.to_string(),
2367            tier,
2368            session_id: session_id.clone(),
2369            project_id: project_id.clone(),
2370            source,
2371            source_path: None,
2372            source_mtime: None,
2373            source_size: None,
2374            source_hash: None,
2375            metadata,
2376        };
2377        let chunk_ids = manager.store_message(request).await?;
2378
2379        Ok(ToolResult {
2380            output: format!("stored {} chunk(s) in {} memory", chunk_ids.len(), tier),
2381            metadata: json!({
2382                "ok": true,
2383                "chunk_ids": chunk_ids,
2384                "count": chunk_ids.len(),
2385                "tier": tier.to_string(),
2386                "session_id": session_id,
2387                "project_id": project_id,
2388                "allow_global": allow_global,
2389                "embedding_status": health.status,
2390                "embedding_reason": health.reason,
2391                "db_path": db_path,
2392            }),
2393        })
2394    }
2395}
2396
2397struct MemoryListTool;
2398#[async_trait]
2399impl Tool for MemoryListTool {
2400    fn schema(&self) -> ToolSchema {
2401        ToolSchema {
2402            name: "memory_list".to_string(),
2403            description: "List stored memory chunks for auditing and knowledge-base browsing."
2404                .to_string(),
2405            input_schema: json!({
2406                "type":"object",
2407                "properties":{
2408                    "tier":{"type":"string","enum":["session","project","global","all"]},
2409                    "session_id":{"type":"string"},
2410                    "project_id":{"type":"string"},
2411                    "limit":{"type":"integer","minimum":1,"maximum":200},
2412                    "allow_global":{"type":"boolean"},
2413                    "db_path":{"type":"string"}
2414                }
2415            }),
2416        }
2417    }
2418
2419    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
2420        let session_id = args
2421            .get("session_id")
2422            .and_then(|v| v.as_str())
2423            .map(str::trim)
2424            .filter(|s| !s.is_empty())
2425            .map(ToString::to_string);
2426        let project_id = args
2427            .get("project_id")
2428            .and_then(|v| v.as_str())
2429            .map(str::trim)
2430            .filter(|s| !s.is_empty())
2431            .map(ToString::to_string);
2432        let allow_global = global_memory_enabled(&args);
2433        let limit = args
2434            .get("limit")
2435            .and_then(|v| v.as_i64())
2436            .unwrap_or(50)
2437            .clamp(1, 200) as usize;
2438
2439        let tier = args
2440            .get("tier")
2441            .and_then(|v| v.as_str())
2442            .map(|s| s.trim().to_ascii_lowercase())
2443            .unwrap_or_else(|| "all".to_string());
2444        if tier == "global" && !allow_global {
2445            return Ok(ToolResult {
2446                output: "tier=global requires allow_global=true".to_string(),
2447                metadata: json!({"ok": false, "reason": "global_scope_disabled"}),
2448            });
2449        }
2450        if session_id.is_none() && project_id.is_none() && tier != "global" && !allow_global {
2451            return Ok(ToolResult {
2452                output: "memory_list requires session_id/project_id, or allow_global=true for global listing".to_string(),
2453                metadata: json!({"ok": false, "reason": "missing_scope"}),
2454            });
2455        }
2456
2457        let db_path = resolve_memory_db_path(&args);
2458        let manager = MemoryManager::new(&db_path).await?;
2459
2460        let mut chunks: Vec<tandem_memory::types::MemoryChunk> = Vec::new();
2461        match tier.as_str() {
2462            "session" => {
2463                let Some(sid) = session_id.as_deref() else {
2464                    return Ok(ToolResult {
2465                        output: "tier=session requires session_id".to_string(),
2466                        metadata: json!({"ok": false, "reason": "missing_session_scope"}),
2467                    });
2468                };
2469                chunks.extend(manager.db().get_session_chunks(sid).await?);
2470            }
2471            "project" => {
2472                let Some(pid) = project_id.as_deref() else {
2473                    return Ok(ToolResult {
2474                        output: "tier=project requires project_id".to_string(),
2475                        metadata: json!({"ok": false, "reason": "missing_project_scope"}),
2476                    });
2477                };
2478                chunks.extend(manager.db().get_project_chunks(pid).await?);
2479            }
2480            "global" => {
2481                chunks.extend(manager.db().get_global_chunks(limit as i64).await?);
2482            }
2483            "all" => {
2484                if let Some(sid) = session_id.as_deref() {
2485                    chunks.extend(manager.db().get_session_chunks(sid).await?);
2486                }
2487                if let Some(pid) = project_id.as_deref() {
2488                    chunks.extend(manager.db().get_project_chunks(pid).await?);
2489                }
2490                if allow_global {
2491                    chunks.extend(manager.db().get_global_chunks(limit as i64).await?);
2492                }
2493            }
2494            _ => {
2495                return Ok(ToolResult {
2496                    output: "memory_list tier must be one of: session, project, global, all"
2497                        .to_string(),
2498                    metadata: json!({"ok": false, "reason": "invalid_tier"}),
2499                });
2500            }
2501        }
2502
2503        chunks.sort_by(|a, b| b.created_at.cmp(&a.created_at));
2504        chunks.truncate(limit);
2505        let rows = chunks
2506            .iter()
2507            .map(|chunk| {
2508                json!({
2509                    "chunk_id": chunk.id,
2510                    "tier": chunk.tier.to_string(),
2511                    "session_id": chunk.session_id,
2512                    "project_id": chunk.project_id,
2513                    "source": chunk.source,
2514                    "content": chunk.content,
2515                    "created_at": chunk.created_at,
2516                    "metadata": chunk.metadata,
2517                })
2518            })
2519            .collect::<Vec<_>>();
2520
2521        Ok(ToolResult {
2522            output: serde_json::to_string_pretty(&rows).unwrap_or_default(),
2523            metadata: json!({
2524                "ok": true,
2525                "count": rows.len(),
2526                "limit": limit,
2527                "tier": tier,
2528                "session_id": session_id,
2529                "project_id": project_id,
2530                "allow_global": allow_global,
2531                "db_path": db_path,
2532            }),
2533        })
2534    }
2535}
2536
2537fn resolve_memory_db_path(args: &Value) -> PathBuf {
2538    if let Some(path) = args
2539        .get("db_path")
2540        .and_then(|v| v.as_str())
2541        .map(str::trim)
2542        .filter(|s| !s.is_empty())
2543    {
2544        return PathBuf::from(path);
2545    }
2546    if let Ok(path) = std::env::var("TANDEM_MEMORY_DB_PATH") {
2547        let trimmed = path.trim();
2548        if !trimmed.is_empty() {
2549            return PathBuf::from(trimmed);
2550        }
2551    }
2552    PathBuf::from("memory.sqlite")
2553}
2554
2555fn global_memory_enabled(args: &Value) -> bool {
2556    if args
2557        .get("allow_global")
2558        .and_then(|v| v.as_bool())
2559        .unwrap_or(false)
2560    {
2561        return true;
2562    }
2563    let Ok(raw) = std::env::var("TANDEM_ENABLE_GLOBAL_MEMORY") else {
2564        return false;
2565    };
2566    matches!(
2567        raw.trim().to_ascii_lowercase().as_str(),
2568        "1" | "true" | "yes" | "on"
2569    )
2570}
2571
2572struct SkillTool;
2573#[async_trait]
2574impl Tool for SkillTool {
2575    fn schema(&self) -> ToolSchema {
2576        ToolSchema {
2577            name: "skill".to_string(),
2578            description: "List or load installed Tandem skills. Call without name to list available skills; provide name to load full SKILL.md content.".to_string(),
2579            input_schema: json!({"type":"object","properties":{"name":{"type":"string"}}}),
2580        }
2581    }
2582    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
2583        let workspace_root = std::env::current_dir().ok();
2584        let service = SkillService::for_workspace(workspace_root);
2585        let requested = args["name"].as_str().map(str::trim).unwrap_or("");
2586        let allowed_skills = parse_allowed_skills(&args);
2587
2588        if requested.is_empty() {
2589            let mut skills = service.list_skills().unwrap_or_default();
2590            if let Some(allowed) = &allowed_skills {
2591                skills.retain(|s| allowed.contains(&s.name));
2592            }
2593            if skills.is_empty() {
2594                return Ok(ToolResult {
2595                    output: "No skills available.".to_string(),
2596                    metadata: json!({"count": 0, "skills": []}),
2597                });
2598            }
2599            let mut lines = vec![
2600                "Available Tandem skills:".to_string(),
2601                "<available_skills>".to_string(),
2602            ];
2603            for skill in &skills {
2604                lines.push("  <skill>".to_string());
2605                lines.push(format!("    <name>{}</name>", skill.name));
2606                lines.push(format!(
2607                    "    <description>{}</description>",
2608                    escape_xml_text(&skill.description)
2609                ));
2610                lines.push(format!("    <location>{}</location>", skill.path));
2611                lines.push("  </skill>".to_string());
2612            }
2613            lines.push("</available_skills>".to_string());
2614            return Ok(ToolResult {
2615                output: lines.join("\n"),
2616                metadata: json!({"count": skills.len(), "skills": skills}),
2617            });
2618        }
2619
2620        if let Some(allowed) = &allowed_skills {
2621            if !allowed.contains(requested) {
2622                let mut allowed_list = allowed.iter().cloned().collect::<Vec<_>>();
2623                allowed_list.sort();
2624                return Ok(ToolResult {
2625                    output: format!(
2626                        "Skill \"{}\" is not enabled for this agent. Enabled skills: {}",
2627                        requested,
2628                        allowed_list.join(", ")
2629                    ),
2630                    metadata: json!({"name": requested, "enabled": allowed_list}),
2631                });
2632            }
2633        }
2634
2635        let loaded = service.load_skill(requested).map_err(anyhow::Error::msg)?;
2636        let Some(skill) = loaded else {
2637            let available = service
2638                .list_skills()
2639                .unwrap_or_default()
2640                .into_iter()
2641                .map(|s| s.name)
2642                .collect::<Vec<_>>();
2643            return Ok(ToolResult {
2644                output: format!(
2645                    "Skill \"{}\" not found. Available skills: {}",
2646                    requested,
2647                    if available.is_empty() {
2648                        "none".to_string()
2649                    } else {
2650                        available.join(", ")
2651                    }
2652                ),
2653                metadata: json!({"name": requested, "matches": [], "available": available}),
2654            });
2655        };
2656
2657        let files = skill
2658            .files
2659            .iter()
2660            .map(|f| format!("<file>{}</file>", f))
2661            .collect::<Vec<_>>()
2662            .join("\n");
2663        let output = [
2664            format!("<skill_content name=\"{}\">", skill.info.name),
2665            format!("# Skill: {}", skill.info.name),
2666            String::new(),
2667            skill.content.trim().to_string(),
2668            String::new(),
2669            format!("Base directory for this skill: {}", skill.base_dir),
2670            "Relative paths in this skill are resolved from this base directory.".to_string(),
2671            "Note: file list is sampled.".to_string(),
2672            String::new(),
2673            "<skill_files>".to_string(),
2674            files,
2675            "</skill_files>".to_string(),
2676            "</skill_content>".to_string(),
2677        ]
2678        .join("\n");
2679        Ok(ToolResult {
2680            output,
2681            metadata: json!({
2682                "name": skill.info.name,
2683                "dir": skill.base_dir,
2684                "path": skill.info.path
2685            }),
2686        })
2687    }
2688}
2689
2690fn escape_xml_text(input: &str) -> String {
2691    input
2692        .replace('&', "&amp;")
2693        .replace('<', "&lt;")
2694        .replace('>', "&gt;")
2695}
2696
2697fn parse_allowed_skills(args: &Value) -> Option<HashSet<String>> {
2698    let values = args
2699        .get("allowed_skills")
2700        .or_else(|| args.get("allowedSkills"))
2701        .and_then(|v| v.as_array())?;
2702    let out = values
2703        .iter()
2704        .filter_map(|v| v.as_str())
2705        .map(str::trim)
2706        .filter(|s| !s.is_empty())
2707        .map(ToString::to_string)
2708        .collect::<HashSet<_>>();
2709    Some(out)
2710}
2711
2712struct ApplyPatchTool;
2713#[async_trait]
2714impl Tool for ApplyPatchTool {
2715    fn schema(&self) -> ToolSchema {
2716        ToolSchema {
2717            name: "apply_patch".to_string(),
2718            description: "Validate patch text and report applicability".to_string(),
2719            input_schema: json!({"type":"object","properties":{"patchText":{"type":"string"}}}),
2720        }
2721    }
2722    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
2723        let patch = args["patchText"].as_str().unwrap_or("");
2724        let has_begin = patch.contains("*** Begin Patch");
2725        let has_end = patch.contains("*** End Patch");
2726        let file_ops = patch
2727            .lines()
2728            .filter(|line| {
2729                line.starts_with("*** Add File:")
2730                    || line.starts_with("*** Update File:")
2731                    || line.starts_with("*** Delete File:")
2732            })
2733            .count();
2734        let valid = has_begin && has_end && file_ops > 0;
2735        Ok(ToolResult {
2736            output: if valid {
2737                "Patch format validated. Host-level patch application must execute this patch."
2738                    .to_string()
2739            } else {
2740                "Invalid patch format. Expected Begin/End markers and at least one file operation."
2741                    .to_string()
2742            },
2743            metadata: json!({"valid": valid, "fileOps": file_ops}),
2744        })
2745    }
2746}
2747
2748struct BatchTool;
2749#[async_trait]
2750impl Tool for BatchTool {
2751    fn schema(&self) -> ToolSchema {
2752        ToolSchema {
2753            name: "batch".to_string(),
2754            description: "Execute multiple tool calls sequentially".to_string(),
2755            input_schema: json!({
2756                "type":"object",
2757                "properties":{
2758                    "tool_calls":{
2759                        "type":"array",
2760                        "items":{
2761                            "type":"object",
2762                            "properties":{
2763                                "tool":{"type":"string"},
2764                                "name":{"type":"string"},
2765                                "args":{"type":"object"}
2766                            }
2767                        }
2768                    }
2769                }
2770            }),
2771        }
2772    }
2773    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
2774        let calls = args["tool_calls"].as_array().cloned().unwrap_or_default();
2775        let registry = ToolRegistry::new();
2776        let mut outputs = Vec::new();
2777        for call in calls.iter().take(20) {
2778            let Some(tool) = resolve_batch_call_tool_name(call) else {
2779                continue;
2780            };
2781            if tool.is_empty() || tool == "batch" {
2782                continue;
2783            }
2784            let call_args = call.get("args").cloned().unwrap_or_else(|| json!({}));
2785            let mut result = registry.execute(&tool, call_args.clone()).await?;
2786            if result.output.starts_with("Unknown tool:") {
2787                if let Some(fallback_name) = call
2788                    .get("name")
2789                    .and_then(|v| v.as_str())
2790                    .map(str::trim)
2791                    .filter(|s| !s.is_empty() && *s != tool)
2792                {
2793                    result = registry.execute(fallback_name, call_args).await?;
2794                }
2795            }
2796            outputs.push(json!({
2797                "tool": tool,
2798                "output": result.output,
2799                "metadata": result.metadata
2800            }));
2801        }
2802        let count = outputs.len();
2803        Ok(ToolResult {
2804            output: serde_json::to_string_pretty(&outputs).unwrap_or_default(),
2805            metadata: json!({"count": count}),
2806        })
2807    }
2808}
2809
2810struct LspTool;
2811#[async_trait]
2812impl Tool for LspTool {
2813    fn schema(&self) -> ToolSchema {
2814        ToolSchema {
2815            name: "lsp".to_string(),
2816            description: "LSP-like workspace diagnostics and symbol operations".to_string(),
2817            input_schema: json!({"type":"object","properties":{"operation":{"type":"string"},"filePath":{"type":"string"},"symbol":{"type":"string"},"query":{"type":"string"}}}),
2818        }
2819    }
2820    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
2821        let operation = args["operation"].as_str().unwrap_or("symbols");
2822        let workspace_root =
2823            workspace_root_from_args(&args).unwrap_or_else(|| effective_cwd_from_args(&args));
2824        let output = match operation {
2825            "diagnostics" => {
2826                let path = args["filePath"].as_str().unwrap_or("");
2827                match resolve_tool_path(path, &args) {
2828                    Some(resolved_path) => {
2829                        diagnostics_for_path(&resolved_path.to_string_lossy()).await
2830                    }
2831                    None => "missing or unsafe filePath".to_string(),
2832                }
2833            }
2834            "definition" => {
2835                let symbol = args["symbol"].as_str().unwrap_or("");
2836                find_symbol_definition(symbol, &workspace_root).await
2837            }
2838            "references" => {
2839                let symbol = args["symbol"].as_str().unwrap_or("");
2840                find_symbol_references(symbol, &workspace_root).await
2841            }
2842            _ => {
2843                let query = args["query"]
2844                    .as_str()
2845                    .or_else(|| args["symbol"].as_str())
2846                    .unwrap_or("");
2847                list_symbols(query, &workspace_root).await
2848            }
2849        };
2850        Ok(ToolResult {
2851            output,
2852            metadata: json!({"operation": operation, "workspace_root": workspace_root.to_string_lossy()}),
2853        })
2854    }
2855}
2856
2857#[allow(dead_code)]
2858fn _safe_path(path: &str) -> PathBuf {
2859    PathBuf::from(path)
2860}
2861
2862static TODO_SEQ: AtomicU64 = AtomicU64::new(1);
2863
2864fn normalize_todos(items: Vec<Value>) -> Vec<Value> {
2865    items
2866        .into_iter()
2867        .filter_map(|item| {
2868            let obj = item.as_object()?;
2869            let content = obj
2870                .get("content")
2871                .and_then(|v| v.as_str())
2872                .or_else(|| obj.get("text").and_then(|v| v.as_str()))
2873                .unwrap_or("")
2874                .trim()
2875                .to_string();
2876            if content.is_empty() {
2877                return None;
2878            }
2879            let id = obj
2880                .get("id")
2881                .and_then(|v| v.as_str())
2882                .filter(|s| !s.trim().is_empty())
2883                .map(ToString::to_string)
2884                .unwrap_or_else(|| format!("todo-{}", TODO_SEQ.fetch_add(1, Ordering::Relaxed)));
2885            let status = obj
2886                .get("status")
2887                .and_then(|v| v.as_str())
2888                .filter(|s| !s.trim().is_empty())
2889                .map(ToString::to_string)
2890                .unwrap_or_else(|| "pending".to_string());
2891            Some(json!({"id": id, "content": content, "status": status}))
2892        })
2893        .collect()
2894}
2895
2896async fn diagnostics_for_path(path: &str) -> String {
2897    let Ok(content) = fs::read_to_string(path).await else {
2898        return "File not found".to_string();
2899    };
2900    let mut issues = Vec::new();
2901    let mut balance = 0i64;
2902    for (idx, line) in content.lines().enumerate() {
2903        for ch in line.chars() {
2904            if ch == '{' {
2905                balance += 1;
2906            } else if ch == '}' {
2907                balance -= 1;
2908            }
2909        }
2910        if line.contains("TODO") {
2911            issues.push(format!("{path}:{}: TODO marker", idx + 1));
2912        }
2913    }
2914    if balance != 0 {
2915        issues.push(format!("{path}:1: Unbalanced braces"));
2916    }
2917    if issues.is_empty() {
2918        "No diagnostics.".to_string()
2919    } else {
2920        issues.join("\n")
2921    }
2922}
2923
2924async fn list_symbols(query: &str, root: &Path) -> String {
2925    let query = query.to_lowercase();
2926    let rust_fn = Regex::new(r"^\s*(pub\s+)?(async\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)")
2927        .unwrap_or_else(|_| Regex::new("$^").expect("regex"));
2928    let mut out = Vec::new();
2929    for entry in WalkBuilder::new(root).build().flatten() {
2930        if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
2931            continue;
2932        }
2933        let path = entry.path();
2934        let ext = path.extension().and_then(|v| v.to_str()).unwrap_or("");
2935        if !matches!(ext, "rs" | "ts" | "tsx" | "js" | "jsx" | "py") {
2936            continue;
2937        }
2938        if let Ok(content) = fs::read_to_string(path).await {
2939            for (idx, line) in content.lines().enumerate() {
2940                if let Some(captures) = rust_fn.captures(line) {
2941                    let name = captures
2942                        .get(3)
2943                        .map(|m| m.as_str().to_string())
2944                        .unwrap_or_default();
2945                    if query.is_empty() || name.to_lowercase().contains(&query) {
2946                        out.push(format!("{}:{}:fn {}", path.display(), idx + 1, name));
2947                        if out.len() >= 100 {
2948                            return out.join("\n");
2949                        }
2950                    }
2951                }
2952            }
2953        }
2954    }
2955    out.join("\n")
2956}
2957
2958async fn find_symbol_definition(symbol: &str, root: &Path) -> String {
2959    if symbol.trim().is_empty() {
2960        return "missing symbol".to_string();
2961    }
2962    let listed = list_symbols(symbol, root).await;
2963    listed
2964        .lines()
2965        .find(|line| line.ends_with(&format!("fn {symbol}")))
2966        .map(ToString::to_string)
2967        .unwrap_or_else(|| "symbol not found".to_string())
2968}
2969
2970#[cfg(test)]
2971mod tests {
2972    use super::*;
2973    use std::collections::HashSet;
2974
2975    #[test]
2976    fn validator_rejects_array_without_items() {
2977        let schemas = vec![ToolSchema {
2978            name: "bad".to_string(),
2979            description: "bad schema".to_string(),
2980            input_schema: json!({
2981                "type":"object",
2982                "properties":{"todos":{"type":"array"}}
2983            }),
2984        }];
2985        let err = validate_tool_schemas(&schemas).expect_err("expected schema validation failure");
2986        assert_eq!(err.tool_name, "bad");
2987        assert!(err.path.contains("properties.todos"));
2988    }
2989
2990    #[tokio::test]
2991    async fn registry_schemas_are_unique_and_valid() {
2992        let registry = ToolRegistry::new();
2993        let schemas = registry.list().await;
2994        validate_tool_schemas(&schemas).expect("registry tool schemas should validate");
2995        let unique = schemas
2996            .iter()
2997            .map(|schema| schema.name.as_str())
2998            .collect::<HashSet<_>>();
2999        assert_eq!(
3000            unique.len(),
3001            schemas.len(),
3002            "tool schemas must be unique by name"
3003        );
3004    }
3005
3006    #[test]
3007    fn websearch_query_extraction_accepts_aliases_and_nested_shapes() {
3008        let direct = json!({"query":"meaning of life"});
3009        assert_eq!(
3010            extract_websearch_query(&direct).as_deref(),
3011            Some("meaning of life")
3012        );
3013
3014        let alias = json!({"q":"hello"});
3015        assert_eq!(extract_websearch_query(&alias).as_deref(), Some("hello"));
3016
3017        let nested = json!({"arguments":{"search_query":"rust tokio"}});
3018        assert_eq!(
3019            extract_websearch_query(&nested).as_deref(),
3020            Some("rust tokio")
3021        );
3022
3023        let as_string = json!("find docs");
3024        assert_eq!(
3025            extract_websearch_query(&as_string).as_deref(),
3026            Some("find docs")
3027        );
3028    }
3029
3030    #[test]
3031    fn websearch_limit_extraction_clamps_and_reads_nested_fields() {
3032        assert_eq!(extract_websearch_limit(&json!({"limit": 100})), Some(10));
3033        assert_eq!(
3034            extract_websearch_limit(&json!({"arguments":{"numResults": 0}})),
3035            Some(1)
3036        );
3037        assert_eq!(
3038            extract_websearch_limit(&json!({"input":{"num_results": 6}})),
3039            Some(6)
3040        );
3041    }
3042
3043    #[test]
3044    fn test_html_stripping_and_markdown_reduction() {
3045        let html = r#"
3046            <!DOCTYPE html>
3047            <html>
3048            <head>
3049                <title>Test Page</title>
3050                <style>
3051                    body { color: red; }
3052                </style>
3053                <script>
3054                    console.log("noisy script");
3055                </script>
3056            </head>
3057            <body>
3058                <h1>Hello World</h1>
3059                <p>This is a <a href="https://example.com">link</a>.</p>
3060                <noscript>Enable JS</noscript>
3061            </body>
3062            </html>
3063        "#;
3064
3065        let cleaned = strip_html_noise(html);
3066        assert!(!cleaned.contains("noisy script"));
3067        assert!(!cleaned.contains("color: red"));
3068        assert!(!cleaned.contains("Enable JS"));
3069        assert!(cleaned.contains("Hello World"));
3070
3071        let markdown = html2md::parse_html(&cleaned);
3072        let text = markdown_to_text(&markdown);
3073
3074        // Raw length includes all the noise
3075        let raw_len = html.len();
3076        // Markdown length should be significantly smaller
3077        let md_len = markdown.len();
3078
3079        println!("Raw: {}, Markdown: {}", raw_len, md_len);
3080        assert!(
3081            md_len < raw_len / 2,
3082            "Markdown should be < 50% of raw HTML size"
3083        );
3084        assert!(text.contains("Hello World"));
3085        assert!(text.contains("link"));
3086    }
3087
3088    #[tokio::test]
3089    async fn memory_search_requires_scope() {
3090        let tool = MemorySearchTool;
3091        let result = tool
3092            .execute(json!({"query": "deployment strategy"}))
3093            .await
3094            .expect("memory_search should return ToolResult");
3095        assert!(result.output.contains("requires at least one scope"));
3096        assert_eq!(result.metadata["ok"], json!(false));
3097        assert_eq!(result.metadata["reason"], json!("missing_scope"));
3098    }
3099
3100    #[tokio::test]
3101    async fn memory_search_global_requires_opt_in() {
3102        let tool = MemorySearchTool;
3103        let result = tool
3104            .execute(json!({
3105                "query": "deployment strategy",
3106                "session_id": "ses_1",
3107                "tier": "global"
3108            }))
3109            .await
3110            .expect("memory_search should return ToolResult");
3111        assert!(result.output.contains("requires allow_global=true"));
3112        assert_eq!(result.metadata["ok"], json!(false));
3113        assert_eq!(result.metadata["reason"], json!("global_scope_disabled"));
3114    }
3115
3116    #[tokio::test]
3117    async fn memory_store_global_requires_opt_in() {
3118        let tool = MemoryStoreTool;
3119        let result = tool
3120            .execute(json!({
3121                "content": "global pattern",
3122                "tier": "global"
3123            }))
3124            .await
3125            .expect("memory_store should return ToolResult");
3126        assert!(result.output.contains("requires allow_global=true"));
3127        assert_eq!(result.metadata["ok"], json!(false));
3128        assert_eq!(result.metadata["reason"], json!("global_scope_disabled"));
3129    }
3130
3131    #[test]
3132    fn translate_windows_ls_with_all_flag() {
3133        let translated = translate_windows_shell_command("ls -la").expect("translation");
3134        assert!(translated.contains("Get-ChildItem"));
3135        assert!(translated.contains("-Force"));
3136    }
3137
3138    #[test]
3139    fn translate_windows_find_name_pattern() {
3140        let translated =
3141            translate_windows_shell_command("find . -type f -name \"*.rs\"").expect("translation");
3142        assert!(translated.contains("Get-ChildItem"));
3143        assert!(translated.contains("-Recurse"));
3144        assert!(translated.contains("-Filter"));
3145    }
3146
3147    #[test]
3148    fn windows_guardrail_blocks_untranslatable_unix_command() {
3149        assert_eq!(
3150            windows_guardrail_reason("sed -n '1,5p' README.md"),
3151            Some("unix_command_untranslatable")
3152        );
3153    }
3154
3155    #[test]
3156    fn path_policy_rejects_tool_markup_and_globs() {
3157        assert!(resolve_tool_path(
3158            "<tool_call><function=glob><parameter=pattern>**/*</parameter></function></tool_call>",
3159            &json!({})
3160        )
3161        .is_none());
3162        assert!(resolve_tool_path("**/*", &json!({})).is_none());
3163        assert!(resolve_tool_path("/", &json!({})).is_none());
3164        assert!(resolve_tool_path("C:\\", &json!({})).is_none());
3165    }
3166
3167    #[tokio::test]
3168    async fn write_tool_rejects_empty_content_by_default() {
3169        let tool = WriteTool;
3170        let result = tool
3171            .execute(json!({
3172                "path":"target/write_guard_test.txt",
3173                "content":""
3174            }))
3175            .await
3176            .expect("write tool should return ToolResult");
3177        assert!(result.output.contains("non-empty `content`"));
3178        assert_eq!(result.metadata["reason"], json!("empty_content"));
3179        assert!(!Path::new("target/write_guard_test.txt").exists());
3180    }
3181
3182    #[tokio::test]
3183    async fn registry_resolves_default_api_namespaced_tool() {
3184        let registry = ToolRegistry::new();
3185        let result = registry
3186            .execute("default_api:read", json!({"path":"Cargo.toml"}))
3187            .await
3188            .expect("registry execute should return ToolResult");
3189        assert!(!result.output.starts_with("Unknown tool:"));
3190    }
3191
3192    #[tokio::test]
3193    async fn batch_resolves_default_api_namespaced_tool() {
3194        let tool = BatchTool;
3195        let result = tool
3196            .execute(json!({
3197                "tool_calls":[
3198                    {"tool":"default_api:read","args":{"path":"Cargo.toml"}}
3199                ]
3200            }))
3201            .await
3202            .expect("batch should return ToolResult");
3203        assert!(!result.output.contains("Unknown tool: default_api:read"));
3204    }
3205
3206    #[tokio::test]
3207    async fn batch_prefers_name_when_tool_is_default_api_wrapper() {
3208        let tool = BatchTool;
3209        let result = tool
3210            .execute(json!({
3211                "tool_calls":[
3212                    {"tool":"default_api","name":"read","args":{"path":"Cargo.toml"}}
3213                ]
3214            }))
3215            .await
3216            .expect("batch should return ToolResult");
3217        assert!(!result.output.contains("Unknown tool: default_api"));
3218    }
3219
3220    #[tokio::test]
3221    async fn batch_resolves_nested_function_name_for_wrapper_tool() {
3222        let tool = BatchTool;
3223        let result = tool
3224            .execute(json!({
3225                "tool_calls":[
3226                    {
3227                        "tool":"default_api",
3228                        "function":{"name":"read"},
3229                        "args":{"path":"Cargo.toml"}
3230                    }
3231                ]
3232            }))
3233            .await
3234            .expect("batch should return ToolResult");
3235        assert!(!result.output.contains("Unknown tool: default_api"));
3236    }
3237
3238    #[tokio::test]
3239    async fn batch_drops_wrapper_calls_without_resolvable_name() {
3240        let tool = BatchTool;
3241        let result = tool
3242            .execute(json!({
3243                "tool_calls":[
3244                    {"tool":"default_api","args":{"path":"Cargo.toml"}}
3245                ]
3246            }))
3247            .await
3248            .expect("batch should return ToolResult");
3249        assert_eq!(result.metadata["count"], json!(0));
3250    }
3251}
3252
3253async fn find_symbol_references(symbol: &str, root: &Path) -> String {
3254    if symbol.trim().is_empty() {
3255        return "missing symbol".to_string();
3256    }
3257    let escaped = regex::escape(symbol);
3258    let re = Regex::new(&format!(r"\b{}\b", escaped));
3259    let Ok(re) = re else {
3260        return "invalid symbol".to_string();
3261    };
3262    let mut refs = Vec::new();
3263    for entry in WalkBuilder::new(root).build().flatten() {
3264        if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
3265            continue;
3266        }
3267        let path = entry.path();
3268        if let Ok(content) = fs::read_to_string(path).await {
3269            for (idx, line) in content.lines().enumerate() {
3270                if re.is_match(line) {
3271                    refs.push(format!("{}:{}:{}", path.display(), idx + 1, line.trim()));
3272                    if refs.len() >= 200 {
3273                        return refs.join("\n");
3274                    }
3275                }
3276            }
3277        }
3278    }
3279    refs.join("\n")
3280}