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