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