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