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