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