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