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::{AtomicBool, AtomicU64, AtomicUsize, Ordering as AtomicOrdering};
6use std::sync::{Arc, Mutex};
7use std::time::Duration;
8
9use anyhow::anyhow;
10use async_trait::async_trait;
11use grep_matcher::LineTerminator;
12use grep_regex::{RegexMatcher, RegexMatcherBuilder};
13use grep_searcher::sinks::Lossy;
14use grep_searcher::{BinaryDetection, MmapChoice, SearcherBuilder};
15use ignore::{ParallelVisitor, ParallelVisitorBuilder, WalkBuilder, WalkState};
16use regex::Regex;
17use serde_json::{json, Value};
18use tandem_memory::embeddings::{get_embedding_service, EmbeddingService};
19use tandem_skills::SkillService;
20use tokio::fs;
21use tokio::process::Command;
22use tokio::sync::RwLock;
23use tokio_util::sync::CancellationToken;
24
25use futures_util::StreamExt;
26use tandem_agent_teams::compat::{
27 send_message_schema, task_create_schema, task_list_schema, task_schema, task_update_schema,
28 team_create_schema,
29};
30use tandem_agent_teams::{
31 AgentTeamPaths, SendMessageInput, SendMessageType, TaskCreateInput, TaskInput, TaskListInput,
32 TaskUpdateInput, TeamCreateInput,
33};
34use tandem_memory::types::{MemorySearchResult, MemoryTier};
35use tandem_memory::MemoryManager;
36use tandem_types::{SharedToolProgressSink, ToolProgressEvent, ToolResult, ToolSchema};
37
38#[async_trait]
39pub trait Tool: Send + Sync {
40 fn schema(&self) -> ToolSchema;
41 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult>;
42 async fn execute_with_cancel(
43 &self,
44 args: Value,
45 _cancel: CancellationToken,
46 ) -> anyhow::Result<ToolResult> {
47 self.execute(args).await
48 }
49 async fn execute_with_progress(
50 &self,
51 args: Value,
52 cancel: CancellationToken,
53 progress: Option<SharedToolProgressSink>,
54 ) -> anyhow::Result<ToolResult> {
55 let _ = progress;
56 self.execute_with_cancel(args, cancel).await
57 }
58}
59
60#[derive(Clone)]
61pub struct ToolRegistry {
62 tools: Arc<RwLock<HashMap<String, Arc<dyn Tool>>>>,
63 tool_vectors: Arc<RwLock<HashMap<String, Vec<f32>>>>,
64}
65
66impl ToolRegistry {
67 pub fn new() -> Self {
68 let mut map: HashMap<String, Arc<dyn Tool>> = HashMap::new();
69 map.insert("bash".to_string(), Arc::new(BashTool));
70 map.insert("read".to_string(), Arc::new(ReadTool));
71 map.insert("write".to_string(), Arc::new(WriteTool));
72 map.insert("edit".to_string(), Arc::new(EditTool));
73 map.insert("glob".to_string(), Arc::new(GlobTool));
74 map.insert("grep".to_string(), Arc::new(GrepTool));
75 map.insert("webfetch".to_string(), Arc::new(WebFetchTool));
76 map.insert("webfetch_html".to_string(), Arc::new(WebFetchHtmlTool));
77 map.insert("mcp_debug".to_string(), Arc::new(McpDebugTool));
78 let search_backend = SearchBackend::from_env();
79 if search_backend.is_enabled() {
80 map.insert(
81 "websearch".to_string(),
82 Arc::new(WebSearchTool {
83 backend: search_backend,
84 }),
85 );
86 } else {
87 tracing::info!(
88 reason = search_backend.disabled_reason().unwrap_or("unknown"),
89 "builtin websearch disabled because no search backend is configured"
90 );
91 }
92 map.insert("codesearch".to_string(), Arc::new(CodeSearchTool));
93 let todo_tool: Arc<dyn Tool> = Arc::new(TodoWriteTool);
94 map.insert("todo_write".to_string(), todo_tool.clone());
95 map.insert("todowrite".to_string(), todo_tool.clone());
96 map.insert("update_todo_list".to_string(), todo_tool);
97 map.insert("task".to_string(), Arc::new(TaskTool));
98 map.insert("question".to_string(), Arc::new(QuestionTool));
99 map.insert("spawn_agent".to_string(), Arc::new(SpawnAgentTool));
100 map.insert("skill".to_string(), Arc::new(SkillTool));
101 map.insert("memory_store".to_string(), Arc::new(MemoryStoreTool));
102 map.insert("memory_list".to_string(), Arc::new(MemoryListTool));
103 map.insert("memory_search".to_string(), Arc::new(MemorySearchTool));
104 map.insert("memory_delete".to_string(), Arc::new(MemoryDeleteTool));
105 map.insert("apply_patch".to_string(), Arc::new(ApplyPatchTool));
106 map.insert("batch".to_string(), Arc::new(BatchTool));
107 map.insert("lsp".to_string(), Arc::new(LspTool));
108 map.insert("teamcreate".to_string(), Arc::new(TeamCreateTool));
109 map.insert("taskcreate".to_string(), Arc::new(TaskCreateCompatTool));
110 map.insert("taskupdate".to_string(), Arc::new(TaskUpdateCompatTool));
111 map.insert("tasklist".to_string(), Arc::new(TaskListCompatTool));
112 map.insert("sendmessage".to_string(), Arc::new(SendMessageCompatTool));
113 Self {
114 tools: Arc::new(RwLock::new(map)),
115 tool_vectors: Arc::new(RwLock::new(HashMap::new())),
116 }
117 }
118
119 pub async fn list(&self) -> Vec<ToolSchema> {
120 let mut dedup: HashMap<String, ToolSchema> = HashMap::new();
121 for schema in self.tools.read().await.values().map(|t| t.schema()) {
122 dedup.entry(schema.name.clone()).or_insert(schema);
123 }
124 let mut schemas = dedup.into_values().collect::<Vec<_>>();
125 schemas.sort_by(|a, b| a.name.cmp(&b.name));
126 schemas
127 }
128
129 pub async fn register_tool(&self, name: String, tool: Arc<dyn Tool>) {
130 let schema = tool.schema();
131 self.tools.write().await.insert(name.clone(), tool);
132 self.index_tool_schema(&schema).await;
133 if name != schema.name {
134 self.index_tool_name(&name, &schema).await;
135 }
136 }
137
138 pub async fn unregister_tool(&self, name: &str) -> bool {
139 let removed = self.tools.write().await.remove(name);
140 self.tool_vectors.write().await.remove(name);
141 if let Some(tool) = removed {
142 let schema_name = tool.schema().name;
143 self.tool_vectors.write().await.remove(&schema_name);
144 return true;
145 }
146 false
147 }
148
149 pub async fn unregister_by_prefix(&self, prefix: &str) -> usize {
150 let mut tools = self.tools.write().await;
151 let keys = tools
152 .keys()
153 .filter(|name| name.starts_with(prefix))
154 .cloned()
155 .collect::<Vec<_>>();
156 let removed = keys.len();
157 let mut removed_schema_names = Vec::new();
158 for key in keys {
159 if let Some(tool) = tools.remove(&key) {
160 removed_schema_names.push(tool.schema().name);
161 }
162 }
163 drop(tools);
164 let mut vectors = self.tool_vectors.write().await;
165 vectors.retain(|name, _| {
166 !name.starts_with(prefix) && !removed_schema_names.iter().any(|schema| schema == name)
167 });
168 removed
169 }
170
171 pub async fn index_all(&self) {
172 let schemas = self.list().await;
173 if schemas.is_empty() {
174 self.tool_vectors.write().await.clear();
175 return;
176 }
177 let texts = schemas
178 .iter()
179 .map(|schema| format!("{}: {}", schema.name, schema.description))
180 .collect::<Vec<_>>();
181 let service = get_embedding_service().await;
182 let service = service.lock().await;
183 if !service.is_available() {
184 return;
185 }
186 let Ok(vectors) = service.embed_batch(&texts).await else {
187 return;
188 };
189 drop(service);
190 let mut indexed = HashMap::new();
191 for (schema, vector) in schemas.into_iter().zip(vectors) {
192 indexed.insert(schema.name, vector);
193 }
194 *self.tool_vectors.write().await = indexed;
195 }
196
197 async fn index_tool_schema(&self, schema: &ToolSchema) {
198 self.index_tool_name(&schema.name, schema).await;
199 }
200
201 async fn index_tool_name(&self, name: &str, schema: &ToolSchema) {
202 let text = format!("{}: {}", schema.name, schema.description);
203 let service = get_embedding_service().await;
204 let service = service.lock().await;
205 if !service.is_available() {
206 return;
207 }
208 let Ok(vector) = service.embed(&text).await else {
209 return;
210 };
211 drop(service);
212 self.tool_vectors
213 .write()
214 .await
215 .insert(name.to_string(), vector);
216 }
217
218 pub async fn retrieve(&self, query: &str, k: usize) -> Vec<ToolSchema> {
219 if k == 0 {
220 return Vec::new();
221 }
222 let service = get_embedding_service().await;
223 let service = service.lock().await;
224 if !service.is_available() {
225 drop(service);
226 return self.list().await;
227 }
228 let Ok(query_vec) = service.embed(query).await else {
229 drop(service);
230 return self.list().await;
231 };
232 drop(service);
233
234 let vectors = self.tool_vectors.read().await;
235 if vectors.is_empty() {
236 drop(vectors);
237 return self.list().await;
238 }
239 let tools = self.tools.read().await;
240 let mut scored = vectors
241 .iter()
242 .map(|(name, vector)| {
243 (
244 EmbeddingService::cosine_similarity(&query_vec, vector),
245 name.clone(),
246 )
247 })
248 .collect::<Vec<_>>();
249 scored.sort_by(|a, b| {
250 b.0.partial_cmp(&a.0)
251 .unwrap_or(std::cmp::Ordering::Equal)
252 .then_with(|| a.1.cmp(&b.1))
253 });
254 let mut out = Vec::new();
255 let mut seen = HashSet::new();
256 for (_, name) in scored.into_iter().take(k) {
257 let Some(tool) = tools.get(&name) else {
258 continue;
259 };
260 let schema = tool.schema();
261 if seen.insert(schema.name.clone()) {
262 out.push(schema);
263 }
264 }
265 if out.is_empty() {
266 self.list().await
267 } else {
268 out
269 }
270 }
271
272 pub async fn mcp_server_names(&self) -> Vec<String> {
273 let mut names = HashSet::new();
274 for schema in self.list().await {
275 let mut parts = schema.name.split('.');
276 if parts.next() == Some("mcp") {
277 if let Some(server) = parts.next() {
278 if !server.trim().is_empty() {
279 names.insert(server.to_string());
280 }
281 }
282 }
283 }
284 let mut sorted = names.into_iter().collect::<Vec<_>>();
285 sorted.sort();
286 sorted
287 }
288
289 pub async fn execute(&self, name: &str, args: Value) -> anyhow::Result<ToolResult> {
290 let tool = {
291 let tools = self.tools.read().await;
292 resolve_registered_tool(&tools, name)
293 };
294 let Some(tool) = tool else {
295 return Ok(ToolResult {
296 output: format!("Unknown tool: {name}"),
297 metadata: json!({}),
298 });
299 };
300 tool.execute(args).await
301 }
302
303 pub async fn execute_with_cancel(
304 &self,
305 name: &str,
306 args: Value,
307 cancel: CancellationToken,
308 ) -> anyhow::Result<ToolResult> {
309 self.execute_with_cancel_and_progress(name, args, cancel, None)
310 .await
311 }
312
313 pub async fn execute_with_cancel_and_progress(
314 &self,
315 name: &str,
316 args: Value,
317 cancel: CancellationToken,
318 progress: Option<SharedToolProgressSink>,
319 ) -> anyhow::Result<ToolResult> {
320 let tool = {
321 let tools = self.tools.read().await;
322 resolve_registered_tool(&tools, name)
323 };
324 let Some(tool) = tool else {
325 return Ok(ToolResult {
326 output: format!("Unknown tool: {name}"),
327 metadata: json!({}),
328 });
329 };
330 tool.execute_with_progress(args, cancel, progress).await
331 }
332}
333
334#[derive(Clone, Debug, PartialEq, Eq)]
335enum SearchBackendKind {
336 Disabled,
337 Auto,
338 Tandem,
339 Searxng,
340 Exa,
341 Brave,
342}
343
344#[derive(Clone, Debug)]
345enum SearchBackend {
346 Disabled {
347 reason: String,
348 },
349 Auto {
350 backends: Vec<SearchBackend>,
351 },
352 Tandem {
353 base_url: String,
354 timeout_ms: u64,
355 },
356 Searxng {
357 base_url: String,
358 engines: Option<String>,
359 timeout_ms: u64,
360 },
361 Exa {
362 api_key: String,
363 timeout_ms: u64,
364 },
365 Brave {
366 api_key: String,
367 timeout_ms: u64,
368 },
369}
370
371impl SearchBackend {
372 fn from_env() -> Self {
373 let explicit = std::env::var("TANDEM_SEARCH_BACKEND")
374 .ok()
375 .map(|value| value.trim().to_ascii_lowercase())
376 .filter(|value| !value.is_empty());
377 let timeout_ms = search_backend_timeout_ms();
378
379 match explicit.as_deref() {
380 Some("none") | Some("disabled") => {
381 return Self::Disabled {
382 reason: "TANDEM_SEARCH_BACKEND explicitly disabled websearch".to_string(),
383 };
384 }
385 Some("auto") => {
386 return search_backend_from_auto_env(timeout_ms);
387 }
388 Some("tandem") => {
389 return search_backend_from_tandem_env(timeout_ms, true);
390 }
391 Some("searxng") => {
392 return search_backend_from_searxng_env(timeout_ms).unwrap_or_else(|| {
393 Self::Disabled {
394 reason: "TANDEM_SEARCH_BACKEND=searxng but TANDEM_SEARXNG_URL is missing"
395 .to_string(),
396 }
397 });
398 }
399 Some("exa") => {
400 return search_backend_from_exa_env(timeout_ms).unwrap_or_else(|| Self::Disabled {
401 reason:
402 "TANDEM_SEARCH_BACKEND=exa but EXA_API_KEY/TANDEM_EXA_API_KEY is missing"
403 .to_string(),
404 });
405 }
406 Some("brave") => {
407 return search_backend_from_brave_env(timeout_ms).unwrap_or_else(|| {
408 Self::Disabled {
409 reason:
410 "TANDEM_SEARCH_BACKEND=brave but BRAVE_SEARCH_API_KEY/TANDEM_BRAVE_SEARCH_API_KEY is missing"
411 .to_string(),
412 }
413 });
414 }
415 Some(other) => {
416 return Self::Disabled {
417 reason: format!(
418 "TANDEM_SEARCH_BACKEND `{other}` is unsupported; expected auto, tandem, searxng, exa, brave, or none"
419 ),
420 };
421 }
422 None => {}
423 }
424 search_backend_from_auto_env(timeout_ms)
425 }
426
427 fn is_enabled(&self) -> bool {
428 !matches!(self, Self::Disabled { .. })
429 }
430
431 fn kind(&self) -> SearchBackendKind {
432 match self {
433 Self::Disabled { .. } => SearchBackendKind::Disabled,
434 Self::Auto { .. } => SearchBackendKind::Auto,
435 Self::Tandem { .. } => SearchBackendKind::Tandem,
436 Self::Searxng { .. } => SearchBackendKind::Searxng,
437 Self::Exa { .. } => SearchBackendKind::Exa,
438 Self::Brave { .. } => SearchBackendKind::Brave,
439 }
440 }
441
442 fn name(&self) -> &'static str {
443 match self.kind() {
444 SearchBackendKind::Disabled => "disabled",
445 SearchBackendKind::Auto => "auto",
446 SearchBackendKind::Tandem => "tandem",
447 SearchBackendKind::Searxng => "searxng",
448 SearchBackendKind::Exa => "exa",
449 SearchBackendKind::Brave => "brave",
450 }
451 }
452
453 fn disabled_reason(&self) -> Option<&str> {
454 match self {
455 Self::Disabled { reason } => Some(reason.as_str()),
456 _ => None,
457 }
458 }
459
460 fn schema_description(&self) -> String {
461 match self {
462 Self::Auto { .. } => {
463 "Search web results using the configured search backends with automatic failover"
464 .to_string()
465 }
466 Self::Tandem { .. } => {
467 "Search web results using Tandem's hosted search backend".to_string()
468 }
469 Self::Searxng { .. } => {
470 "Search web results using the configured SearxNG backend".to_string()
471 }
472 Self::Exa { .. } => "Search web results using the configured Exa backend".to_string(),
473 Self::Brave { .. } => {
474 "Search web results using the configured Brave Search backend".to_string()
475 }
476 Self::Disabled { .. } => {
477 "Search web results using the configured search backend".to_string()
478 }
479 }
480 }
481}
482
483fn has_nonempty_env_var(name: &str) -> bool {
484 std::env::var(name)
485 .ok()
486 .map(|value| !value.trim().is_empty())
487 .unwrap_or(false)
488}
489
490fn search_backend_timeout_ms() -> u64 {
491 std::env::var("TANDEM_SEARCH_TIMEOUT_MS")
492 .ok()
493 .and_then(|value| value.trim().parse::<u64>().ok())
494 .unwrap_or(10_000)
495 .clamp(1_000, 120_000)
496}
497
498fn search_backend_from_tandem_env(timeout_ms: u64, allow_default_url: bool) -> SearchBackend {
499 const DEFAULT_TANDEM_SEARCH_URL: &str = "https://search.tandem.ac";
500 let base_url = std::env::var("TANDEM_SEARCH_URL")
501 .ok()
502 .map(|value| value.trim().trim_end_matches('/').to_string())
503 .filter(|value| !value.is_empty())
504 .or_else(|| allow_default_url.then(|| DEFAULT_TANDEM_SEARCH_URL.to_string()));
505 match base_url {
506 Some(base_url) => SearchBackend::Tandem {
507 base_url,
508 timeout_ms,
509 },
510 None => SearchBackend::Disabled {
511 reason: "TANDEM_SEARCH_BACKEND=tandem but TANDEM_SEARCH_URL is missing".to_string(),
512 },
513 }
514}
515
516fn search_backend_from_searxng_env(timeout_ms: u64) -> Option<SearchBackend> {
517 let base_url = std::env::var("TANDEM_SEARXNG_URL").ok()?;
518 let base_url = base_url.trim().trim_end_matches('/').to_string();
519 if base_url.is_empty() {
520 return None;
521 }
522 let engines = std::env::var("TANDEM_SEARXNG_ENGINES")
523 .ok()
524 .map(|value| value.trim().to_string())
525 .filter(|value| !value.is_empty());
526 Some(SearchBackend::Searxng {
527 base_url,
528 engines,
529 timeout_ms,
530 })
531}
532
533fn search_backend_from_exa_env(timeout_ms: u64) -> Option<SearchBackend> {
534 let api_key = std::env::var("TANDEM_EXA_API_KEY")
535 .ok()
536 .or_else(|| std::env::var("TANDEM_EXA_SEARCH_API_KEY").ok())
537 .or_else(|| std::env::var("EXA_API_KEY").ok())?;
538 let api_key = api_key.trim().to_string();
539 if api_key.is_empty() {
540 return None;
541 }
542 Some(SearchBackend::Exa {
543 api_key,
544 timeout_ms,
545 })
546}
547
548fn search_backend_from_brave_env(timeout_ms: u64) -> Option<SearchBackend> {
549 let api_key = std::env::var("TANDEM_BRAVE_SEARCH_API_KEY")
550 .ok()
551 .or_else(|| std::env::var("BRAVE_SEARCH_API_KEY").ok())?;
552 let api_key = api_key.trim().to_string();
553 if api_key.is_empty() {
554 return None;
555 }
556 Some(SearchBackend::Brave {
557 api_key,
558 timeout_ms,
559 })
560}
561
562fn search_backend_auto_candidates(timeout_ms: u64) -> Vec<SearchBackend> {
563 let mut backends = Vec::new();
564
565 if has_nonempty_env_var("TANDEM_SEARCH_URL") {
566 backends.push(search_backend_from_tandem_env(timeout_ms, false));
567 }
568 if let Some(config) = search_backend_from_searxng_env(timeout_ms) {
569 backends.push(config);
570 }
571 if let Some(config) = search_backend_from_brave_env(timeout_ms) {
572 backends.push(config);
573 }
574 if let Some(config) = search_backend_from_exa_env(timeout_ms) {
575 backends.push(config);
576 }
577 if backends.is_empty() {
578 backends.push(search_backend_from_tandem_env(timeout_ms, true));
579 }
580
581 backends
582 .into_iter()
583 .filter(|backend| !matches!(backend, SearchBackend::Disabled { .. }))
584 .collect()
585}
586
587fn search_backend_from_auto_env(timeout_ms: u64) -> SearchBackend {
588 let backends = search_backend_auto_candidates(timeout_ms);
589 match backends.len() {
590 0 => SearchBackend::Disabled {
591 reason:
592 "set TANDEM_SEARCH_URL or configure tandem, searxng, brave, or exa to enable websearch"
593 .to_string(),
594 },
595 1 => backends.into_iter().next().expect("single backend"),
596 _ => SearchBackend::Auto { backends },
597 }
598}
599
600#[derive(Clone, Debug, serde::Serialize)]
601struct SearchResultEntry {
602 title: String,
603 url: String,
604 snippet: String,
605 source: String,
606}
607
608fn canonical_tool_name(name: &str) -> String {
609 match name.trim().to_ascii_lowercase().replace('-', "_").as_str() {
610 "todowrite" | "update_todo_list" | "update_todos" => "todo_write".to_string(),
611 "run_command" | "shell" | "powershell" | "cmd" => "bash".to_string(),
612 other => other.to_string(),
613 }
614}
615
616fn strip_known_tool_namespace(name: &str) -> Option<String> {
617 const PREFIXES: [&str; 8] = [
618 "default_api:",
619 "default_api.",
620 "functions.",
621 "function.",
622 "tools.",
623 "tool.",
624 "builtin:",
625 "builtin.",
626 ];
627 for prefix in PREFIXES {
628 if let Some(rest) = name.strip_prefix(prefix) {
629 let trimmed = rest.trim();
630 if !trimmed.is_empty() {
631 return Some(trimmed.to_string());
632 }
633 }
634 }
635 None
636}
637
638fn resolve_registered_tool(
639 tools: &HashMap<String, Arc<dyn Tool>>,
640 requested_name: &str,
641) -> Option<Arc<dyn Tool>> {
642 let canonical = canonical_tool_name(requested_name);
643 if let Some(tool) = tools.get(&canonical) {
644 return Some(tool.clone());
645 }
646 if let Some(stripped) = strip_known_tool_namespace(&canonical) {
647 let stripped = canonical_tool_name(&stripped);
648 if let Some(tool) = tools.get(&stripped) {
649 return Some(tool.clone());
650 }
651 }
652 None
653}
654
655fn is_batch_wrapper_tool_name(name: &str) -> bool {
656 matches!(
657 canonical_tool_name(name).as_str(),
658 "default_api" | "default" | "api" | "function" | "functions" | "tool" | "tools"
659 )
660}
661
662fn non_empty_batch_str(value: Option<&Value>) -> Option<&str> {
663 trimmed_non_empty_str(value)
664}
665
666fn resolve_batch_call_tool_name(call: &Value) -> Option<String> {
667 let tool = non_empty_batch_str(call.get("tool"))
668 .or_else(|| {
669 call.get("tool")
670 .and_then(|v| v.as_object())
671 .and_then(|obj| non_empty_batch_str(obj.get("name")))
672 })
673 .or_else(|| {
674 call.get("function")
675 .and_then(|v| v.as_object())
676 .and_then(|obj| non_empty_batch_str(obj.get("tool")))
677 })
678 .or_else(|| {
679 call.get("function_call")
680 .and_then(|v| v.as_object())
681 .and_then(|obj| non_empty_batch_str(obj.get("tool")))
682 })
683 .or_else(|| {
684 call.get("call")
685 .and_then(|v| v.as_object())
686 .and_then(|obj| non_empty_batch_str(obj.get("tool")))
687 });
688 let name = non_empty_batch_str(call.get("name"))
689 .or_else(|| {
690 call.get("function")
691 .and_then(|v| v.as_object())
692 .and_then(|obj| non_empty_batch_str(obj.get("name")))
693 })
694 .or_else(|| {
695 call.get("function_call")
696 .and_then(|v| v.as_object())
697 .and_then(|obj| non_empty_batch_str(obj.get("name")))
698 })
699 .or_else(|| {
700 call.get("call")
701 .and_then(|v| v.as_object())
702 .and_then(|obj| non_empty_batch_str(obj.get("name")))
703 })
704 .or_else(|| {
705 call.get("tool")
706 .and_then(|v| v.as_object())
707 .and_then(|obj| non_empty_batch_str(obj.get("name")))
708 });
709
710 match (tool, name) {
711 (Some(t), Some(n)) => {
712 if is_batch_wrapper_tool_name(t) {
713 Some(n.to_string())
714 } else if let Some(stripped) = strip_known_tool_namespace(t) {
715 Some(stripped)
716 } else {
717 Some(t.to_string())
718 }
719 }
720 (Some(t), None) => {
721 if is_batch_wrapper_tool_name(t) {
722 None
723 } else if let Some(stripped) = strip_known_tool_namespace(t) {
724 Some(stripped)
725 } else {
726 Some(t.to_string())
727 }
728 }
729 (None, Some(n)) => Some(n.to_string()),
730 (None, None) => None,
731 }
732}
733
734impl Default for ToolRegistry {
735 fn default() -> Self {
736 Self::new()
737 }
738}
739
740#[derive(Debug, Clone, PartialEq, Eq)]
741pub struct ToolSchemaValidationError {
742 pub tool_name: String,
743 pub path: String,
744 pub reason: String,
745}
746
747impl std::fmt::Display for ToolSchemaValidationError {
748 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
749 write!(
750 f,
751 "invalid tool schema `{}` at `{}`: {}",
752 self.tool_name, self.path, self.reason
753 )
754 }
755}
756
757impl std::error::Error for ToolSchemaValidationError {}
758
759pub fn validate_tool_schemas(schemas: &[ToolSchema]) -> Result<(), ToolSchemaValidationError> {
760 for schema in schemas {
761 validate_schema_node(&schema.name, "$", &schema.input_schema)?;
762 }
763 Ok(())
764}
765
766fn validate_schema_node(
767 tool_name: &str,
768 path: &str,
769 value: &Value,
770) -> Result<(), ToolSchemaValidationError> {
771 let Some(obj) = value.as_object() else {
772 if let Some(arr) = value.as_array() {
773 for (idx, item) in arr.iter().enumerate() {
774 validate_schema_node(tool_name, &format!("{path}[{idx}]"), item)?;
775 }
776 }
777 return Ok(());
778 };
779
780 if obj.get("type").and_then(|t| t.as_str()) == Some("array") && !obj.contains_key("items") {
781 return Err(ToolSchemaValidationError {
782 tool_name: tool_name.to_string(),
783 path: path.to_string(),
784 reason: "array schema missing items".to_string(),
785 });
786 }
787
788 if let Some(items) = obj.get("items") {
789 validate_schema_node(tool_name, &format!("{path}.items"), items)?;
790 }
791 if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
792 for (key, child) in props {
793 validate_schema_node(tool_name, &format!("{path}.properties.{key}"), child)?;
794 }
795 }
796 if let Some(additional_props) = obj.get("additionalProperties") {
797 validate_schema_node(
798 tool_name,
799 &format!("{path}.additionalProperties"),
800 additional_props,
801 )?;
802 }
803 if let Some(one_of) = obj.get("oneOf").and_then(|v| v.as_array()) {
804 for (idx, child) in one_of.iter().enumerate() {
805 validate_schema_node(tool_name, &format!("{path}.oneOf[{idx}]"), child)?;
806 }
807 }
808 if let Some(any_of) = obj.get("anyOf").and_then(|v| v.as_array()) {
809 for (idx, child) in any_of.iter().enumerate() {
810 validate_schema_node(tool_name, &format!("{path}.anyOf[{idx}]"), child)?;
811 }
812 }
813 if let Some(all_of) = obj.get("allOf").and_then(|v| v.as_array()) {
814 for (idx, child) in all_of.iter().enumerate() {
815 validate_schema_node(tool_name, &format!("{path}.allOf[{idx}]"), child)?;
816 }
817 }
818
819 Ok(())
820}
821
822fn workspace_root_from_args(args: &Value) -> Option<PathBuf> {
823 args.get("__workspace_root")
824 .and_then(|v| v.as_str())
825 .map(str::trim)
826 .filter(|s| !s.is_empty())
827 .map(PathBuf::from)
828}
829
830fn effective_cwd_from_args(args: &Value) -> PathBuf {
831 args.get("__effective_cwd")
832 .and_then(|v| v.as_str())
833 .map(str::trim)
834 .filter(|s| !s.is_empty())
835 .map(PathBuf::from)
836 .or_else(|| workspace_root_from_args(args))
837 .or_else(|| std::env::current_dir().ok())
838 .unwrap_or_else(|| PathBuf::from("."))
839}
840
841fn normalize_path_for_compare(path: &Path) -> PathBuf {
842 let mut normalized = PathBuf::new();
843 for component in path.components() {
844 match component {
845 std::path::Component::CurDir => {}
846 std::path::Component::ParentDir => {
847 let _ = normalized.pop();
848 }
849 other => normalized.push(other.as_os_str()),
850 }
851 }
852 normalized
853}
854
855fn normalize_existing_or_lexical(path: &Path) -> PathBuf {
856 path.canonicalize()
857 .unwrap_or_else(|_| normalize_path_for_compare(path))
858}
859
860fn is_within_workspace_root(path: &Path, workspace_root: &Path) -> bool {
861 let candidate_lexical = normalize_path_for_compare(path);
864 let root_lexical = normalize_path_for_compare(workspace_root);
865 if candidate_lexical.starts_with(&root_lexical) {
866 return true;
867 }
868
869 let candidate = normalize_existing_or_lexical(path);
872 let root = normalize_existing_or_lexical(workspace_root);
873 candidate.starts_with(root)
874}
875
876fn resolve_tool_path(path: &str, args: &Value) -> Option<PathBuf> {
877 let trimmed = path.trim();
878 if trimmed.is_empty() {
879 return None;
880 }
881 if trimmed == "." || trimmed == "./" || trimmed == ".\\" {
882 let cwd = effective_cwd_from_args(args);
883 if let Some(workspace_root) = workspace_root_from_args(args) {
884 if !is_within_workspace_root(&cwd, &workspace_root) {
885 return None;
886 }
887 }
888 return Some(cwd);
889 }
890 if is_root_only_path_token(trimmed) || is_malformed_tool_path_token(trimmed) {
891 return None;
892 }
893 let raw = Path::new(trimmed);
894 if !raw.is_absolute()
895 && raw
896 .components()
897 .any(|c| matches!(c, std::path::Component::ParentDir))
898 {
899 return None;
900 }
901
902 let resolved = if raw.is_absolute() {
903 raw.to_path_buf()
904 } else {
905 effective_cwd_from_args(args).join(raw)
906 };
907
908 if let Some(workspace_root) = workspace_root_from_args(args) {
909 if !is_within_workspace_root(&resolved, &workspace_root) {
910 return None;
911 }
912 } else if raw.is_absolute() {
913 return None;
914 }
915
916 Some(resolved)
917}
918
919fn resolve_walk_root(path: &str, args: &Value) -> Option<PathBuf> {
920 let trimmed = path.trim();
921 if trimmed.is_empty() {
922 return None;
923 }
924 if is_malformed_tool_path_token(trimmed) {
925 return None;
926 }
927 resolve_tool_path(path, args)
928}
929
930fn resolve_read_path_fallback(path: &str, args: &Value) -> Option<PathBuf> {
931 let token = path.trim();
932 if token.is_empty() {
933 return None;
934 }
935 let raw = Path::new(token);
936 if raw.is_absolute() || token.contains('\\') || token.contains('/') || raw.extension().is_none()
937 {
938 return None;
939 }
940
941 let workspace_root = workspace_root_from_args(args);
942 let effective_cwd = effective_cwd_from_args(args);
943 let mut search_roots = vec![effective_cwd.clone()];
944 if let Some(root) = workspace_root.as_ref() {
945 if *root != effective_cwd {
946 search_roots.push(root.clone());
947 }
948 }
949
950 let token_lower = token.to_lowercase();
951 for root in search_roots {
952 if let Some(workspace_root) = workspace_root.as_ref() {
953 if !is_within_workspace_root(&root, workspace_root) {
954 continue;
955 }
956 }
957
958 let mut matches = Vec::new();
959 for entry in WalkBuilder::new(&root).build().flatten() {
960 if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
961 continue;
962 }
963 let candidate = entry.path();
964 if let Some(workspace_root) = workspace_root.as_ref() {
965 if !is_within_workspace_root(candidate, workspace_root) {
966 continue;
967 }
968 }
969 let file_name = candidate
970 .file_name()
971 .and_then(|name| name.to_str())
972 .unwrap_or_default()
973 .to_lowercase();
974 if file_name == token_lower || file_name.ends_with(&token_lower) {
975 matches.push(candidate.to_path_buf());
976 if matches.len() > 8 {
977 break;
978 }
979 }
980 }
981
982 if matches.len() == 1 {
983 return matches.into_iter().next();
984 }
985 }
986
987 None
988}
989
990fn sandbox_path_denied_result(path: &str, args: &Value) -> ToolResult {
991 let requested = path.trim();
992 let workspace_root = workspace_root_from_args(args);
993 let effective_cwd = effective_cwd_from_args(args);
994 let suggested_path = Path::new(requested)
995 .file_name()
996 .filter(|name| !name.is_empty())
997 .map(PathBuf::from)
998 .map(|name| {
999 if let Some(root) = workspace_root.as_ref() {
1000 if is_within_workspace_root(&effective_cwd, root) {
1001 effective_cwd.join(name)
1002 } else {
1003 root.join(name)
1004 }
1005 } else {
1006 effective_cwd.join(name)
1007 }
1008 });
1009
1010 let mut output =
1011 "path denied by sandbox policy (outside workspace root, malformed path, or missing workspace context)"
1012 .to_string();
1013 if let Some(suggested) = suggested_path.as_ref() {
1014 output.push_str(&format!(
1015 "\nrequested: {}\ntry: {}",
1016 requested,
1017 suggested.to_string_lossy()
1018 ));
1019 }
1020 if let Some(root) = workspace_root.as_ref() {
1021 output.push_str(&format!("\nworkspace_root: {}", root.to_string_lossy()));
1022 }
1023
1024 ToolResult {
1025 output,
1026 metadata: json!({
1027 "path": path,
1028 "workspace_root": workspace_root.map(|p| p.to_string_lossy().to_string()),
1029 "effective_cwd": effective_cwd.to_string_lossy().to_string(),
1030 "suggested_path": suggested_path.map(|p| p.to_string_lossy().to_string())
1031 }),
1032 }
1033}
1034
1035fn is_root_only_path_token(path: &str) -> bool {
1036 if matches!(path, "/" | "\\" | "." | ".." | "~") {
1037 return true;
1038 }
1039 let bytes = path.as_bytes();
1040 if bytes.len() == 2 && bytes[1] == b':' && (bytes[0] as char).is_ascii_alphabetic() {
1041 return true;
1042 }
1043 if bytes.len() == 3
1044 && bytes[1] == b':'
1045 && (bytes[0] as char).is_ascii_alphabetic()
1046 && (bytes[2] == b'\\' || bytes[2] == b'/')
1047 {
1048 return true;
1049 }
1050 false
1051}
1052
1053fn is_malformed_tool_path_token(path: &str) -> bool {
1054 let lower = path.to_ascii_lowercase();
1055 if lower.contains("<tool_call")
1056 || lower.contains("</tool_call")
1057 || lower.contains("<function=")
1058 || lower.contains("<parameter=")
1059 || lower.contains("</function>")
1060 || lower.contains("</parameter>")
1061 {
1062 return true;
1063 }
1064 if path.contains('\n') || path.contains('\r') {
1065 return true;
1066 }
1067 if path.contains('*') {
1068 return true;
1069 }
1070 if path.contains('?') {
1073 let trimmed = path.trim();
1074 let is_windows_verbatim = trimmed.starts_with("\\\\?\\") || trimmed.starts_with("//?/");
1075 if !is_windows_verbatim {
1076 return true;
1077 }
1078 }
1079 false
1080}
1081
1082fn is_malformed_tool_pattern_token(pattern: &str) -> bool {
1083 let lower = pattern.to_ascii_lowercase();
1084 if lower.contains("<tool_call")
1085 || lower.contains("</tool_call")
1086 || lower.contains("<function=")
1087 || lower.contains("<parameter=")
1088 || lower.contains("</function>")
1089 || lower.contains("</parameter>")
1090 {
1091 return true;
1092 }
1093 if pattern.contains('\n') || pattern.contains('\r') {
1094 return true;
1095 }
1096 false
1097}
1098
1099struct WriteTool;
1102#[async_trait]
1103impl Tool for WriteTool {
1104 fn schema(&self) -> ToolSchema {
1105 tool_schema_with_capabilities(
1106 "write",
1107 "Write file contents",
1108 json!({
1109 "type":"object",
1110 "properties":{
1111 "path":{"type":"string"},
1112 "content":{"type":"string"},
1113 "allow_empty":{"type":"boolean"}
1114 },
1115 "required":["path", "content"]
1116 }),
1117 workspace_write_capabilities(),
1118 )
1119 }
1120 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1121 let path = args["path"].as_str().unwrap_or("").trim();
1122 let content = args["content"].as_str();
1123 let allow_empty = args
1124 .get("allow_empty")
1125 .and_then(|v| v.as_bool())
1126 .unwrap_or(false);
1127 let Some(path_buf) = resolve_tool_path(path, &args) else {
1128 return Ok(sandbox_path_denied_result(path, &args));
1129 };
1130 let Some(content) = content else {
1131 return Ok(ToolResult {
1132 output: "write requires `content`".to_string(),
1133 metadata: json!({"ok": false, "reason": "missing_content", "path": path}),
1134 });
1135 };
1136 if content.is_empty() && !allow_empty {
1137 return Ok(ToolResult {
1138 output: "write requires non-empty `content` (or set allow_empty=true)".to_string(),
1139 metadata: json!({"ok": false, "reason": "empty_content", "path": path}),
1140 });
1141 }
1142 if let Some(parent) = path_buf.parent() {
1143 if !parent.as_os_str().is_empty() {
1144 fs::create_dir_all(parent).await?;
1145 }
1146 }
1147 fs::write(&path_buf, content).await?;
1148 Ok(ToolResult {
1149 output: "ok".to_string(),
1150 metadata: json!({"path": path_buf.to_string_lossy()}),
1151 })
1152 }
1153}
1154
1155struct EditTool;
1156#[async_trait]
1157impl Tool for EditTool {
1158 fn schema(&self) -> ToolSchema {
1159 tool_schema_with_capabilities(
1160 "edit",
1161 "String replacement edit",
1162 json!({
1163 "type":"object",
1164 "properties":{
1165 "path":{"type":"string"},
1166 "old":{"type":"string"},
1167 "new":{"type":"string"}
1168 },
1169 "required":["path", "old", "new"]
1170 }),
1171 workspace_write_capabilities(),
1172 )
1173 }
1174 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1175 let path = args["path"].as_str().unwrap_or("");
1176 let old = args["old"].as_str().unwrap_or("");
1177 let new = args["new"].as_str().unwrap_or("");
1178 let Some(path_buf) = resolve_tool_path(path, &args) else {
1179 return Ok(sandbox_path_denied_result(path, &args));
1180 };
1181 let content = fs::read_to_string(&path_buf).await.unwrap_or_default();
1182 let updated = content.replace(old, new);
1183 fs::write(&path_buf, updated).await?;
1184 Ok(ToolResult {
1185 output: "ok".to_string(),
1186 metadata: json!({"path": path_buf.to_string_lossy()}),
1187 })
1188 }
1189}
1190
1191struct GlobTool;
1192
1193fn normalize_recursive_wildcard_pattern(pattern: &str) -> Option<String> {
1194 let mut changed = false;
1195 let normalized = pattern
1196 .split('/')
1197 .flat_map(|component| {
1198 if let Some(tail) = component.strip_prefix("**") {
1199 if !tail.is_empty() {
1200 changed = true;
1201 let normalized_tail = if tail.starts_with('.') || tail.starts_with('{') {
1202 format!("*{tail}")
1203 } else {
1204 tail.to_string()
1205 };
1206 return vec!["**".to_string(), normalized_tail];
1207 }
1208 }
1209 vec![component.to_string()]
1210 })
1211 .collect::<Vec<_>>()
1212 .join("/");
1213 changed.then_some(normalized)
1214}
1215
1216#[async_trait]
1217impl Tool for GlobTool {
1218 fn schema(&self) -> ToolSchema {
1219 tool_schema_with_capabilities(
1220 "glob",
1221 "Find files by glob",
1222 json!({"type":"object","properties":{"pattern":{"type":"string"}}}),
1223 workspace_search_capabilities(),
1224 )
1225 }
1226 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1227 let pattern = args["pattern"].as_str().unwrap_or("*");
1228 if pattern.contains("..") {
1229 return Ok(ToolResult {
1230 output: "pattern denied by sandbox policy".to_string(),
1231 metadata: json!({"pattern": pattern}),
1232 });
1233 }
1234 if is_malformed_tool_pattern_token(pattern) {
1235 return Ok(ToolResult {
1236 output: "pattern denied by sandbox policy".to_string(),
1237 metadata: json!({"pattern": pattern}),
1238 });
1239 }
1240 let workspace_root = workspace_root_from_args(&args);
1241 let effective_cwd = effective_cwd_from_args(&args);
1242 let scoped_pattern = if Path::new(pattern).is_absolute() {
1243 pattern.to_string()
1244 } else {
1245 effective_cwd.join(pattern).to_string_lossy().to_string()
1246 };
1247 let mut files = Vec::new();
1248 let mut effective_pattern = scoped_pattern.clone();
1249 let paths = match glob::glob(&scoped_pattern) {
1250 Ok(paths) => paths,
1251 Err(err) => {
1252 if let Some(normalized) = normalize_recursive_wildcard_pattern(&scoped_pattern) {
1253 if let Ok(paths) = glob::glob(&normalized) {
1254 effective_pattern = normalized;
1255 paths
1256 } else {
1257 return Err(err.into());
1258 }
1259 } else {
1260 return Err(err.into());
1261 }
1262 }
1263 };
1264 for path in paths.flatten() {
1265 if is_discovery_ignored_path(&path) {
1266 continue;
1267 }
1268 if let Some(root) = workspace_root.as_ref() {
1269 if !is_within_workspace_root(&path, root) {
1270 continue;
1271 }
1272 }
1273 files.push(path.display().to_string());
1274 if files.len() >= 100 {
1275 break;
1276 }
1277 }
1278 Ok(ToolResult {
1279 output: files.join("\n"),
1280 metadata: json!({
1281 "count": files.len(),
1282 "effective_cwd": effective_cwd,
1283 "workspace_root": workspace_root,
1284 "pattern": pattern,
1285 "effective_pattern": effective_pattern
1286 }),
1287 })
1288 }
1289}
1290
1291fn is_discovery_ignored_path(path: &Path) -> bool {
1292 let components: Vec<_> = path.components().collect();
1293 for (idx, component) in components.iter().enumerate() {
1294 if component.as_os_str() == ".tandem" {
1295 let next = components
1296 .get(idx + 1)
1297 .map(|component| component.as_os_str());
1298 return next != Some(std::ffi::OsStr::new("artifacts"));
1299 }
1300 }
1301 false
1302}
1303
1304struct GrepTool;
1305
1306#[derive(Debug, Clone)]
1307struct GrepHit {
1308 path: String,
1309 line: usize,
1310 text: String,
1311 ordinal: usize,
1312}
1313
1314fn grep_hit_to_value(hit: &GrepHit) -> Value {
1315 json!({
1316 "path": hit.path,
1317 "line": hit.line,
1318 "text": hit.text,
1319 "ordinal": hit.ordinal,
1320 })
1321}
1322
1323fn emit_grep_progress_chunk(
1324 progress: Option<&SharedToolProgressSink>,
1325 tool: &str,
1326 hits: &[GrepHit],
1327) {
1328 let Some(progress) = progress else {
1329 return;
1330 };
1331 if hits.is_empty() {
1332 return;
1333 }
1334 progress.publish(ToolProgressEvent::new(
1335 "tool.search.chunk",
1336 json!({
1337 "tool": tool,
1338 "hits": hits.iter().map(grep_hit_to_value).collect::<Vec<_>>(),
1339 }),
1340 ));
1341}
1342
1343fn emit_grep_progress_done(
1344 progress: Option<&SharedToolProgressSink>,
1345 tool: &str,
1346 path: &Path,
1347 total_hits: usize,
1348 truncated: bool,
1349 cancelled: bool,
1350) {
1351 let Some(progress) = progress else {
1352 return;
1353 };
1354 progress.publish(ToolProgressEvent::new(
1355 "tool.search.done",
1356 json!({
1357 "tool": tool,
1358 "path": path.to_string_lossy(),
1359 "count": total_hits,
1360 "truncated": truncated,
1361 "cancelled": cancelled,
1362 }),
1363 ));
1364}
1365
1366struct GrepSearchState {
1367 hits: Mutex<Vec<GrepHit>>,
1368 hit_count: AtomicUsize,
1369 stop: AtomicBool,
1370 cancel: CancellationToken,
1371 limit: usize,
1372 chunk_size: usize,
1373 progress: Option<SharedToolProgressSink>,
1374}