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
38mod builtin_tools;
39mod tool_metadata;
40use builtin_tools::*;
41use tool_metadata::*;
42
43#[async_trait]
44pub trait Tool: Send + Sync {
45 fn schema(&self) -> ToolSchema;
46 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult>;
47 async fn execute_with_cancel(
48 &self,
49 args: Value,
50 _cancel: CancellationToken,
51 ) -> anyhow::Result<ToolResult> {
52 self.execute(args).await
53 }
54 async fn execute_with_progress(
55 &self,
56 args: Value,
57 cancel: CancellationToken,
58 progress: Option<SharedToolProgressSink>,
59 ) -> anyhow::Result<ToolResult> {
60 let _ = progress;
61 self.execute_with_cancel(args, cancel).await
62 }
63}
64
65#[derive(Clone)]
66pub struct ToolRegistry {
67 tools: Arc<RwLock<HashMap<String, Arc<dyn Tool>>>>,
68 tool_vectors: Arc<RwLock<HashMap<String, Vec<f32>>>>,
69}
70
71impl ToolRegistry {
72 pub fn new() -> Self {
73 let mut map: HashMap<String, Arc<dyn Tool>> = HashMap::new();
74 map.insert("bash".to_string(), Arc::new(BashTool));
75 map.insert("read".to_string(), Arc::new(ReadTool));
76 map.insert("write".to_string(), Arc::new(WriteTool));
77 map.insert("edit".to_string(), Arc::new(EditTool));
78 map.insert("glob".to_string(), Arc::new(GlobTool));
79 map.insert("grep".to_string(), Arc::new(GrepTool));
80 map.insert("webfetch".to_string(), Arc::new(WebFetchTool));
81 map.insert("webfetch_html".to_string(), Arc::new(WebFetchHtmlTool));
82 map.insert("mcp_debug".to_string(), Arc::new(McpDebugTool));
83 let search_backend = SearchBackend::from_env();
84 if search_backend.is_enabled() {
85 map.insert(
86 "websearch".to_string(),
87 Arc::new(WebSearchTool {
88 backend: search_backend,
89 }),
90 );
91 } else {
92 tracing::info!(
93 reason = search_backend.disabled_reason().unwrap_or("unknown"),
94 "builtin websearch disabled because no search backend is configured"
95 );
96 }
97 map.insert("codesearch".to_string(), Arc::new(CodeSearchTool));
98 let todo_tool: Arc<dyn Tool> = Arc::new(TodoWriteTool);
99 map.insert("todo_write".to_string(), todo_tool.clone());
100 map.insert("todowrite".to_string(), todo_tool.clone());
101 map.insert("update_todo_list".to_string(), todo_tool);
102 map.insert("task".to_string(), Arc::new(TaskTool));
103 map.insert("question".to_string(), Arc::new(QuestionTool));
104 map.insert("spawn_agent".to_string(), Arc::new(SpawnAgentTool));
105 map.insert("skill".to_string(), Arc::new(SkillTool));
106 map.insert("memory_store".to_string(), Arc::new(MemoryStoreTool));
107 map.insert("memory_list".to_string(), Arc::new(MemoryListTool));
108 map.insert("memory_search".to_string(), Arc::new(MemorySearchTool));
109 map.insert("memory_delete".to_string(), Arc::new(MemoryDeleteTool));
110 map.insert("apply_patch".to_string(), Arc::new(ApplyPatchTool));
111 map.insert("batch".to_string(), Arc::new(BatchTool));
112 map.insert("lsp".to_string(), Arc::new(LspTool));
113 map.insert("teamcreate".to_string(), Arc::new(TeamCreateTool));
114 map.insert("taskcreate".to_string(), Arc::new(TaskCreateCompatTool));
115 map.insert("taskupdate".to_string(), Arc::new(TaskUpdateCompatTool));
116 map.insert("tasklist".to_string(), Arc::new(TaskListCompatTool));
117 map.insert("sendmessage".to_string(), Arc::new(SendMessageCompatTool));
118 Self {
119 tools: Arc::new(RwLock::new(map)),
120 tool_vectors: Arc::new(RwLock::new(HashMap::new())),
121 }
122 }
123
124 pub async fn list(&self) -> Vec<ToolSchema> {
125 let mut dedup: HashMap<String, ToolSchema> = HashMap::new();
126 for schema in self.tools.read().await.values().map(|t| t.schema()) {
127 dedup.entry(schema.name.clone()).or_insert(schema);
128 }
129 let mut schemas = dedup.into_values().collect::<Vec<_>>();
130 schemas.sort_by(|a, b| a.name.cmp(&b.name));
131 schemas
132 }
133
134 pub async fn register_tool(&self, name: String, tool: Arc<dyn Tool>) {
135 let schema = tool.schema();
136 self.tools.write().await.insert(name.clone(), tool);
137 self.index_tool_schema(&schema).await;
138 if name != schema.name {
139 self.index_tool_name(&name, &schema).await;
140 }
141 }
142
143 pub async fn unregister_tool(&self, name: &str) -> bool {
144 let removed = self.tools.write().await.remove(name);
145 self.tool_vectors.write().await.remove(name);
146 if let Some(tool) = removed {
147 let schema_name = tool.schema().name;
148 self.tool_vectors.write().await.remove(&schema_name);
149 return true;
150 }
151 false
152 }
153
154 pub async fn unregister_by_prefix(&self, prefix: &str) -> usize {
155 let mut tools = self.tools.write().await;
156 let keys = tools
157 .keys()
158 .filter(|name| name.starts_with(prefix))
159 .cloned()
160 .collect::<Vec<_>>();
161 let removed = keys.len();
162 let mut removed_schema_names = Vec::new();
163 for key in keys {
164 if let Some(tool) = tools.remove(&key) {
165 removed_schema_names.push(tool.schema().name);
166 }
167 }
168 drop(tools);
169 let mut vectors = self.tool_vectors.write().await;
170 vectors.retain(|name, _| {
171 !name.starts_with(prefix) && !removed_schema_names.iter().any(|schema| schema == name)
172 });
173 removed
174 }
175
176 pub async fn index_all(&self) {
177 let schemas = self.list().await;
178 if schemas.is_empty() {
179 self.tool_vectors.write().await.clear();
180 return;
181 }
182 let texts = schemas
183 .iter()
184 .map(|schema| format!("{}: {}", schema.name, schema.description))
185 .collect::<Vec<_>>();
186 let service = get_embedding_service().await;
187 let service = service.lock().await;
188 if !service.is_available() {
189 return;
190 }
191 let Ok(vectors) = service.embed_batch(&texts).await else {
192 return;
193 };
194 drop(service);
195 let mut indexed = HashMap::new();
196 for (schema, vector) in schemas.into_iter().zip(vectors) {
197 indexed.insert(schema.name, vector);
198 }
199 *self.tool_vectors.write().await = indexed;
200 }
201
202 async fn index_tool_schema(&self, schema: &ToolSchema) {
203 self.index_tool_name(&schema.name, schema).await;
204 }
205
206 async fn index_tool_name(&self, name: &str, schema: &ToolSchema) {
207 let text = format!("{}: {}", schema.name, schema.description);
208 let service = get_embedding_service().await;
209 let service = service.lock().await;
210 if !service.is_available() {
211 return;
212 }
213 let Ok(vector) = service.embed(&text).await else {
214 return;
215 };
216 drop(service);
217 self.tool_vectors
218 .write()
219 .await
220 .insert(name.to_string(), vector);
221 }
222
223 pub async fn retrieve(&self, query: &str, k: usize) -> Vec<ToolSchema> {
224 if k == 0 {
225 return Vec::new();
226 }
227 let service = get_embedding_service().await;
228 let service = service.lock().await;
229 if !service.is_available() {
230 drop(service);
231 return self.list().await;
232 }
233 let Ok(query_vec) = service.embed(query).await else {
234 drop(service);
235 return self.list().await;
236 };
237 drop(service);
238
239 let vectors = self.tool_vectors.read().await;
240 if vectors.is_empty() {
241 drop(vectors);
242 return self.list().await;
243 }
244 let tools = self.tools.read().await;
245 let mut scored = vectors
246 .iter()
247 .map(|(name, vector)| {
248 (
249 EmbeddingService::cosine_similarity(&query_vec, vector),
250 name.clone(),
251 )
252 })
253 .collect::<Vec<_>>();
254 scored.sort_by(|a, b| {
255 b.0.partial_cmp(&a.0)
256 .unwrap_or(std::cmp::Ordering::Equal)
257 .then_with(|| a.1.cmp(&b.1))
258 });
259 let mut out = Vec::new();
260 let mut seen = HashSet::new();
261 for (_, name) in scored.into_iter().take(k) {
262 let Some(tool) = tools.get(&name) else {
263 continue;
264 };
265 let schema = tool.schema();
266 if seen.insert(schema.name.clone()) {
267 out.push(schema);
268 }
269 }
270 if out.is_empty() {
271 self.list().await
272 } else {
273 out
274 }
275 }
276
277 pub async fn mcp_server_names(&self) -> Vec<String> {
278 let mut names = HashSet::new();
279 for schema in self.list().await {
280 let mut parts = schema.name.split('.');
281 if parts.next() == Some("mcp") {
282 if let Some(server) = parts.next() {
283 if !server.trim().is_empty() {
284 names.insert(server.to_string());
285 }
286 }
287 }
288 }
289 let mut sorted = names.into_iter().collect::<Vec<_>>();
290 sorted.sort();
291 sorted
292 }
293
294 pub async fn execute(&self, name: &str, args: Value) -> anyhow::Result<ToolResult> {
295 let tool = {
296 let tools = self.tools.read().await;
297 resolve_registered_tool(&tools, name)
298 };
299 let Some(tool) = tool else {
300 return Ok(ToolResult {
301 output: format!("Unknown tool: {name}"),
302 metadata: json!({}),
303 });
304 };
305 tool.execute(args).await
306 }
307
308 pub async fn execute_with_cancel(
309 &self,
310 name: &str,
311 args: Value,
312 cancel: CancellationToken,
313 ) -> anyhow::Result<ToolResult> {
314 self.execute_with_cancel_and_progress(name, args, cancel, None)
315 .await
316 }
317
318 pub async fn execute_with_cancel_and_progress(
319 &self,
320 name: &str,
321 args: Value,
322 cancel: CancellationToken,
323 progress: Option<SharedToolProgressSink>,
324 ) -> anyhow::Result<ToolResult> {
325 let tool = {
326 let tools = self.tools.read().await;
327 resolve_registered_tool(&tools, name)
328 };
329 let Some(tool) = tool else {
330 return Ok(ToolResult {
331 output: format!("Unknown tool: {name}"),
332 metadata: json!({}),
333 });
334 };
335 tool.execute_with_progress(args, cancel, progress).await
336 }
337}
338
339#[derive(Clone, Debug, PartialEq, Eq)]
340enum SearchBackendKind {
341 Disabled,
342 Auto,
343 Tandem,
344 Searxng,
345 Exa,
346 Brave,
347}
348
349#[derive(Clone, Debug)]
350enum SearchBackend {
351 Disabled {
352 reason: String,
353 },
354 Auto {
355 backends: Vec<SearchBackend>,
356 },
357 Tandem {
358 base_url: String,
359 timeout_ms: u64,
360 },
361 Searxng {
362 base_url: String,
363 engines: Option<String>,
364 timeout_ms: u64,
365 },
366 Exa {
367 api_key: String,
368 timeout_ms: u64,
369 },
370 Brave {
371 api_key: String,
372 timeout_ms: u64,
373 },
374}
375
376impl SearchBackend {
377 fn from_env() -> Self {
378 let explicit = std::env::var("TANDEM_SEARCH_BACKEND")
379 .ok()
380 .map(|value| value.trim().to_ascii_lowercase())
381 .filter(|value| !value.is_empty());
382 let timeout_ms = search_backend_timeout_ms();
383
384 match explicit.as_deref() {
385 Some("none") | Some("disabled") => {
386 return Self::Disabled {
387 reason: "TANDEM_SEARCH_BACKEND explicitly disabled websearch".to_string(),
388 };
389 }
390 Some("auto") => {
391 return search_backend_from_auto_env(timeout_ms);
392 }
393 Some("tandem") => {
394 return search_backend_from_tandem_env(timeout_ms, true);
395 }
396 Some("searxng") => {
397 return search_backend_from_searxng_env(timeout_ms).unwrap_or_else(|| {
398 Self::Disabled {
399 reason: "TANDEM_SEARCH_BACKEND=searxng but TANDEM_SEARXNG_URL is missing"
400 .to_string(),
401 }
402 });
403 }
404 Some("exa") => {
405 return search_backend_from_exa_env(timeout_ms).unwrap_or_else(|| Self::Disabled {
406 reason:
407 "TANDEM_SEARCH_BACKEND=exa but EXA_API_KEY/TANDEM_EXA_API_KEY is missing"
408 .to_string(),
409 });
410 }
411 Some("brave") => {
412 return search_backend_from_brave_env(timeout_ms).unwrap_or_else(|| {
413 Self::Disabled {
414 reason:
415 "TANDEM_SEARCH_BACKEND=brave but BRAVE_SEARCH_API_KEY/TANDEM_BRAVE_SEARCH_API_KEY is missing"
416 .to_string(),
417 }
418 });
419 }
420 Some(other) => {
421 return Self::Disabled {
422 reason: format!(
423 "TANDEM_SEARCH_BACKEND `{other}` is unsupported; expected auto, tandem, searxng, exa, brave, or none"
424 ),
425 };
426 }
427 None => {}
428 }
429 search_backend_from_auto_env(timeout_ms)
430 }
431
432 fn is_enabled(&self) -> bool {
433 !matches!(self, Self::Disabled { .. })
434 }
435
436 fn kind(&self) -> SearchBackendKind {
437 match self {
438 Self::Disabled { .. } => SearchBackendKind::Disabled,
439 Self::Auto { .. } => SearchBackendKind::Auto,
440 Self::Tandem { .. } => SearchBackendKind::Tandem,
441 Self::Searxng { .. } => SearchBackendKind::Searxng,
442 Self::Exa { .. } => SearchBackendKind::Exa,
443 Self::Brave { .. } => SearchBackendKind::Brave,
444 }
445 }
446
447 fn name(&self) -> &'static str {
448 match self.kind() {
449 SearchBackendKind::Disabled => "disabled",
450 SearchBackendKind::Auto => "auto",
451 SearchBackendKind::Tandem => "tandem",
452 SearchBackendKind::Searxng => "searxng",
453 SearchBackendKind::Exa => "exa",
454 SearchBackendKind::Brave => "brave",
455 }
456 }
457
458 fn disabled_reason(&self) -> Option<&str> {
459 match self {
460 Self::Disabled { reason } => Some(reason.as_str()),
461 _ => None,
462 }
463 }
464
465 fn schema_description(&self) -> String {
466 match self {
467 Self::Auto { .. } => {
468 "Search web results using the configured search backends with automatic failover"
469 .to_string()
470 }
471 Self::Tandem { .. } => {
472 "Search web results using Tandem's hosted search backend".to_string()
473 }
474 Self::Searxng { .. } => {
475 "Search web results using the configured SearxNG backend".to_string()
476 }
477 Self::Exa { .. } => "Search web results using the configured Exa backend".to_string(),
478 Self::Brave { .. } => {
479 "Search web results using the configured Brave Search backend".to_string()
480 }
481 Self::Disabled { .. } => {
482 "Search web results using the configured search backend".to_string()
483 }
484 }
485 }
486}
487
488fn has_nonempty_env_var(name: &str) -> bool {
489 std::env::var(name)
490 .ok()
491 .map(|value| !value.trim().is_empty())
492 .unwrap_or(false)
493}
494
495fn search_backend_timeout_ms() -> u64 {
496 std::env::var("TANDEM_SEARCH_TIMEOUT_MS")
497 .ok()
498 .and_then(|value| value.trim().parse::<u64>().ok())
499 .unwrap_or(10_000)
500 .clamp(1_000, 120_000)
501}
502
503fn search_backend_from_tandem_env(timeout_ms: u64, allow_default_url: bool) -> SearchBackend {
504 const DEFAULT_TANDEM_SEARCH_URL: &str = "https://search.tandem.ac";
505 let base_url = std::env::var("TANDEM_SEARCH_URL")
506 .ok()
507 .map(|value| value.trim().trim_end_matches('/').to_string())
508 .filter(|value| !value.is_empty())
509 .or_else(|| allow_default_url.then(|| DEFAULT_TANDEM_SEARCH_URL.to_string()));
510 match base_url {
511 Some(base_url) => SearchBackend::Tandem {
512 base_url,
513 timeout_ms,
514 },
515 None => SearchBackend::Disabled {
516 reason: "TANDEM_SEARCH_BACKEND=tandem but TANDEM_SEARCH_URL is missing".to_string(),
517 },
518 }
519}
520
521fn search_backend_from_searxng_env(timeout_ms: u64) -> Option<SearchBackend> {
522 let base_url = std::env::var("TANDEM_SEARXNG_URL").ok()?;
523 let base_url = base_url.trim().trim_end_matches('/').to_string();
524 if base_url.is_empty() {
525 return None;
526 }
527 let engines = std::env::var("TANDEM_SEARXNG_ENGINES")
528 .ok()
529 .map(|value| value.trim().to_string())
530 .filter(|value| !value.is_empty());
531 Some(SearchBackend::Searxng {
532 base_url,
533 engines,
534 timeout_ms,
535 })
536}
537
538fn search_backend_from_exa_env(timeout_ms: u64) -> Option<SearchBackend> {
539 let api_key = std::env::var("TANDEM_EXA_API_KEY")
540 .ok()
541 .or_else(|| std::env::var("TANDEM_EXA_SEARCH_API_KEY").ok())
542 .or_else(|| std::env::var("EXA_API_KEY").ok())?;
543 let api_key = api_key.trim().to_string();
544 if api_key.is_empty() {
545 return None;
546 }
547 Some(SearchBackend::Exa {
548 api_key,
549 timeout_ms,
550 })
551}
552
553fn search_backend_from_brave_env(timeout_ms: u64) -> Option<SearchBackend> {
554 let api_key = std::env::var("TANDEM_BRAVE_SEARCH_API_KEY")
555 .ok()
556 .or_else(|| std::env::var("BRAVE_SEARCH_API_KEY").ok())?;
557 let api_key = api_key.trim().to_string();
558 if api_key.is_empty() {
559 return None;
560 }
561 Some(SearchBackend::Brave {
562 api_key,
563 timeout_ms,
564 })
565}
566
567fn search_backend_auto_candidates(timeout_ms: u64) -> Vec<SearchBackend> {
568 let mut backends = Vec::new();
569
570 if has_nonempty_env_var("TANDEM_SEARCH_URL") {
571 backends.push(search_backend_from_tandem_env(timeout_ms, false));
572 }
573 if let Some(config) = search_backend_from_searxng_env(timeout_ms) {
574 backends.push(config);
575 }
576 if let Some(config) = search_backend_from_brave_env(timeout_ms) {
577 backends.push(config);
578 }
579 if let Some(config) = search_backend_from_exa_env(timeout_ms) {
580 backends.push(config);
581 }
582 if backends.is_empty() {
583 backends.push(search_backend_from_tandem_env(timeout_ms, true));
584 }
585
586 backends
587 .into_iter()
588 .filter(|backend| !matches!(backend, SearchBackend::Disabled { .. }))
589 .collect()
590}
591
592fn search_backend_from_auto_env(timeout_ms: u64) -> SearchBackend {
593 let backends = search_backend_auto_candidates(timeout_ms);
594 match backends.len() {
595 0 => SearchBackend::Disabled {
596 reason:
597 "set TANDEM_SEARCH_URL or configure tandem, searxng, brave, or exa to enable websearch"
598 .to_string(),
599 },
600 1 => backends.into_iter().next().expect("single backend"),
601 _ => SearchBackend::Auto { backends },
602 }
603}
604
605#[derive(Clone, Debug, serde::Serialize)]
606struct SearchResultEntry {
607 title: String,
608 url: String,
609 snippet: String,
610 source: String,
611}
612
613fn canonical_tool_name(name: &str) -> String {
614 match name.trim().to_ascii_lowercase().replace('-', "_").as_str() {
615 "todowrite" | "update_todo_list" | "update_todos" => "todo_write".to_string(),
616 "run_command" | "shell" | "powershell" | "cmd" => "bash".to_string(),
617 other => other.to_string(),
618 }
619}
620
621fn strip_known_tool_namespace(name: &str) -> Option<String> {
622 const PREFIXES: [&str; 8] = [
623 "default_api:",
624 "default_api.",
625 "functions.",
626 "function.",
627 "tools.",
628 "tool.",
629 "builtin:",
630 "builtin.",
631 ];
632 for prefix in PREFIXES {
633 if let Some(rest) = name.strip_prefix(prefix) {
634 let trimmed = rest.trim();
635 if !trimmed.is_empty() {
636 return Some(trimmed.to_string());
637 }
638 }
639 }
640 None
641}
642
643fn resolve_registered_tool(
644 tools: &HashMap<String, Arc<dyn Tool>>,
645 requested_name: &str,
646) -> Option<Arc<dyn Tool>> {
647 let canonical = canonical_tool_name(requested_name);
648 if let Some(tool) = tools.get(&canonical) {
649 return Some(tool.clone());
650 }
651 if let Some(stripped) = strip_known_tool_namespace(&canonical) {
652 let stripped = canonical_tool_name(&stripped);
653 if let Some(tool) = tools.get(&stripped) {
654 return Some(tool.clone());
655 }
656 }
657 None
658}
659
660fn is_batch_wrapper_tool_name(name: &str) -> bool {
661 matches!(
662 canonical_tool_name(name).as_str(),
663 "default_api" | "default" | "api" | "function" | "functions" | "tool" | "tools"
664 )
665}
666
667fn non_empty_batch_str(value: Option<&Value>) -> Option<&str> {
668 trimmed_non_empty_str(value)
669}
670
671fn resolve_batch_call_tool_name(call: &Value) -> Option<String> {
672 let tool = non_empty_batch_str(call.get("tool"))
673 .or_else(|| {
674 call.get("tool")
675 .and_then(|v| v.as_object())
676 .and_then(|obj| non_empty_batch_str(obj.get("name")))
677 })
678 .or_else(|| {
679 call.get("function")
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("function_call")
685 .and_then(|v| v.as_object())
686 .and_then(|obj| non_empty_batch_str(obj.get("tool")))
687 })
688 .or_else(|| {
689 call.get("call")
690 .and_then(|v| v.as_object())
691 .and_then(|obj| non_empty_batch_str(obj.get("tool")))
692 });
693 let name = non_empty_batch_str(call.get("name"))
694 .or_else(|| {
695 call.get("function")
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("function_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("call")
706 .and_then(|v| v.as_object())
707 .and_then(|obj| non_empty_batch_str(obj.get("name")))
708 })
709 .or_else(|| {
710 call.get("tool")
711 .and_then(|v| v.as_object())
712 .and_then(|obj| non_empty_batch_str(obj.get("name")))
713 });
714
715 match (tool, name) {
716 (Some(t), Some(n)) => {
717 if is_batch_wrapper_tool_name(t) {
718 Some(n.to_string())
719 } else if let Some(stripped) = strip_known_tool_namespace(t) {
720 Some(stripped)
721 } else {
722 Some(t.to_string())
723 }
724 }
725 (Some(t), None) => {
726 if is_batch_wrapper_tool_name(t) {
727 None
728 } else if let Some(stripped) = strip_known_tool_namespace(t) {
729 Some(stripped)
730 } else {
731 Some(t.to_string())
732 }
733 }
734 (None, Some(n)) => Some(n.to_string()),
735 (None, None) => None,
736 }
737}
738
739impl Default for ToolRegistry {
740 fn default() -> Self {
741 Self::new()
742 }
743}
744
745#[derive(Debug, Clone, PartialEq, Eq)]
746pub struct ToolSchemaValidationError {
747 pub tool_name: String,
748 pub path: String,
749 pub reason: String,
750}
751
752impl std::fmt::Display for ToolSchemaValidationError {
753 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
754 write!(
755 f,
756 "invalid tool schema `{}` at `{}`: {}",
757 self.tool_name, self.path, self.reason
758 )
759 }
760}
761
762impl std::error::Error for ToolSchemaValidationError {}
763
764pub fn validate_tool_schemas(schemas: &[ToolSchema]) -> Result<(), ToolSchemaValidationError> {
765 for schema in schemas {
766 validate_schema_node(&schema.name, "$", &schema.input_schema)?;
767 }
768 Ok(())
769}
770
771fn validate_schema_node(
772 tool_name: &str,
773 path: &str,
774 value: &Value,
775) -> Result<(), ToolSchemaValidationError> {
776 let Some(obj) = value.as_object() else {
777 if let Some(arr) = value.as_array() {
778 for (idx, item) in arr.iter().enumerate() {
779 validate_schema_node(tool_name, &format!("{path}[{idx}]"), item)?;
780 }
781 }
782 return Ok(());
783 };
784
785 if obj.get("type").and_then(|t| t.as_str()) == Some("array") && !obj.contains_key("items") {
786 return Err(ToolSchemaValidationError {
787 tool_name: tool_name.to_string(),
788 path: path.to_string(),
789 reason: "array schema missing items".to_string(),
790 });
791 }
792
793 if let Some(items) = obj.get("items") {
794 validate_schema_node(tool_name, &format!("{path}.items"), items)?;
795 }
796 if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
797 for (key, child) in props {
798 validate_schema_node(tool_name, &format!("{path}.properties.{key}"), child)?;
799 }
800 }
801 if let Some(additional_props) = obj.get("additionalProperties") {
802 validate_schema_node(
803 tool_name,
804 &format!("{path}.additionalProperties"),
805 additional_props,
806 )?;
807 }
808 if let Some(one_of) = obj.get("oneOf").and_then(|v| v.as_array()) {
809 for (idx, child) in one_of.iter().enumerate() {
810 validate_schema_node(tool_name, &format!("{path}.oneOf[{idx}]"), child)?;
811 }
812 }
813 if let Some(any_of) = obj.get("anyOf").and_then(|v| v.as_array()) {
814 for (idx, child) in any_of.iter().enumerate() {
815 validate_schema_node(tool_name, &format!("{path}.anyOf[{idx}]"), child)?;
816 }
817 }
818 if let Some(all_of) = obj.get("allOf").and_then(|v| v.as_array()) {
819 for (idx, child) in all_of.iter().enumerate() {
820 validate_schema_node(tool_name, &format!("{path}.allOf[{idx}]"), child)?;
821 }
822 }
823
824 Ok(())
825}
826
827fn workspace_root_from_args(args: &Value) -> Option<PathBuf> {
828 args.get("__workspace_root")
829 .and_then(|v| v.as_str())
830 .map(str::trim)
831 .filter(|s| !s.is_empty())
832 .map(PathBuf::from)
833}
834
835fn effective_cwd_from_args(args: &Value) -> PathBuf {
836 args.get("__effective_cwd")
837 .and_then(|v| v.as_str())
838 .map(str::trim)
839 .filter(|s| !s.is_empty())
840 .map(PathBuf::from)
841 .or_else(|| workspace_root_from_args(args))
842 .or_else(|| std::env::current_dir().ok())
843 .unwrap_or_else(|| PathBuf::from("."))
844}
845
846fn normalize_path_for_compare(path: &Path) -> PathBuf {
847 let mut normalized = PathBuf::new();
848 for component in path.components() {
849 match component {
850 std::path::Component::CurDir => {}
851 std::path::Component::ParentDir => {
852 let _ = normalized.pop();
853 }
854 other => normalized.push(other.as_os_str()),
855 }
856 }
857 normalized
858}
859
860fn normalize_existing_or_lexical(path: &Path) -> PathBuf {
861 path.canonicalize()
862 .unwrap_or_else(|_| normalize_path_for_compare(path))
863}
864
865fn is_within_workspace_root(path: &Path, workspace_root: &Path) -> bool {
866 let candidate_lexical = normalize_path_for_compare(path);
869 let root_lexical = normalize_path_for_compare(workspace_root);
870 if candidate_lexical.starts_with(&root_lexical) {
871 return true;
872 }
873
874 let candidate = normalize_existing_or_lexical(path);
877 let root = normalize_existing_or_lexical(workspace_root);
878 candidate.starts_with(root)
879}
880
881fn resolve_tool_path(path: &str, args: &Value) -> Option<PathBuf> {
882 let trimmed = path.trim();
883 if trimmed.is_empty() {
884 return None;
885 }
886 if trimmed == "." || trimmed == "./" || trimmed == ".\\" {
887 let cwd = effective_cwd_from_args(args);
888 if let Some(workspace_root) = workspace_root_from_args(args) {
889 if !is_within_workspace_root(&cwd, &workspace_root) {
890 return None;
891 }
892 }
893 return Some(cwd);
894 }
895 if is_root_only_path_token(trimmed) || is_malformed_tool_path_token(trimmed) {
896 return None;
897 }
898 let raw = Path::new(trimmed);
899 if !raw.is_absolute()
900 && raw
901 .components()
902 .any(|c| matches!(c, std::path::Component::ParentDir))
903 {
904 return None;
905 }
906
907 let resolved = if raw.is_absolute() {
908 raw.to_path_buf()
909 } else {
910 effective_cwd_from_args(args).join(raw)
911 };
912
913 if let Some(workspace_root) = workspace_root_from_args(args) {
914 if !is_within_workspace_root(&resolved, &workspace_root) {
915 return None;
916 }
917 } else if raw.is_absolute() {
918 return None;
919 }
920
921 Some(resolved)
922}
923
924fn resolve_walk_root(path: &str, args: &Value) -> Option<PathBuf> {
925 let trimmed = path.trim();
926 if trimmed.is_empty() {
927 return None;
928 }
929 if is_malformed_tool_path_token(trimmed) {
930 return None;
931 }
932 resolve_tool_path(path, args)
933}
934
935fn resolve_read_path_fallback(path: &str, args: &Value) -> Option<PathBuf> {
936 let token = path.trim();
937 if token.is_empty() {
938 return None;
939 }
940 let raw = Path::new(token);
941 if raw.is_absolute() || token.contains('\\') || token.contains('/') || raw.extension().is_none()
942 {
943 return None;
944 }
945
946 let workspace_root = workspace_root_from_args(args);
947 let effective_cwd = effective_cwd_from_args(args);
948 let mut search_roots = vec![effective_cwd.clone()];
949 if let Some(root) = workspace_root.as_ref() {
950 if *root != effective_cwd {
951 search_roots.push(root.clone());
952 }
953 }
954
955 let token_lower = token.to_lowercase();
956 for root in search_roots {
957 if let Some(workspace_root) = workspace_root.as_ref() {
958 if !is_within_workspace_root(&root, workspace_root) {
959 continue;
960 }
961 }
962
963 let mut matches = Vec::new();
964 for entry in WalkBuilder::new(&root).build().flatten() {
965 if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
966 continue;
967 }
968 let candidate = entry.path();
969 if let Some(workspace_root) = workspace_root.as_ref() {
970 if !is_within_workspace_root(candidate, workspace_root) {
971 continue;
972 }
973 }
974 let file_name = candidate
975 .file_name()
976 .and_then(|name| name.to_str())
977 .unwrap_or_default()
978 .to_lowercase();
979 if file_name == token_lower || file_name.ends_with(&token_lower) {
980 matches.push(candidate.to_path_buf());
981 if matches.len() > 8 {
982 break;
983 }
984 }
985 }
986
987 if matches.len() == 1 {
988 return matches.into_iter().next();
989 }
990 }
991
992 None
993}
994
995fn sandbox_path_denied_result(path: &str, args: &Value) -> ToolResult {
996 let requested = path.trim();
997 let workspace_root = workspace_root_from_args(args);
998 let effective_cwd = effective_cwd_from_args(args);
999 let suggested_path = Path::new(requested)
1000 .file_name()
1001 .filter(|name| !name.is_empty())
1002 .map(PathBuf::from)
1003 .map(|name| {
1004 if let Some(root) = workspace_root.as_ref() {
1005 if is_within_workspace_root(&effective_cwd, root) {
1006 effective_cwd.join(name)
1007 } else {
1008 root.join(name)
1009 }
1010 } else {
1011 effective_cwd.join(name)
1012 }
1013 });
1014
1015 let mut output =
1016 "path denied by sandbox policy (outside workspace root, malformed path, or missing workspace context)"
1017 .to_string();
1018 if let Some(suggested) = suggested_path.as_ref() {
1019 output.push_str(&format!(
1020 "\nrequested: {}\ntry: {}",
1021 requested,
1022 suggested.to_string_lossy()
1023 ));
1024 }
1025 if let Some(root) = workspace_root.as_ref() {
1026 output.push_str(&format!("\nworkspace_root: {}", root.to_string_lossy()));
1027 }
1028
1029 ToolResult {
1030 output,
1031 metadata: json!({
1032 "path": path,
1033 "workspace_root": workspace_root.map(|p| p.to_string_lossy().to_string()),
1034 "effective_cwd": effective_cwd.to_string_lossy().to_string(),
1035 "suggested_path": suggested_path.map(|p| p.to_string_lossy().to_string())
1036 }),
1037 }
1038}
1039
1040fn is_root_only_path_token(path: &str) -> bool {
1041 if matches!(path, "/" | "\\" | "." | ".." | "~") {
1042 return true;
1043 }
1044 let bytes = path.as_bytes();
1045 if bytes.len() == 2 && bytes[1] == b':' && (bytes[0] as char).is_ascii_alphabetic() {
1046 return true;
1047 }
1048 if bytes.len() == 3
1049 && bytes[1] == b':'
1050 && (bytes[0] as char).is_ascii_alphabetic()
1051 && (bytes[2] == b'\\' || bytes[2] == b'/')
1052 {
1053 return true;
1054 }
1055 false
1056}
1057
1058fn is_malformed_tool_path_token(path: &str) -> bool {
1059 let lower = path.to_ascii_lowercase();
1060 if lower.contains("<tool_call")
1061 || lower.contains("</tool_call")
1062 || lower.contains("<function=")
1063 || lower.contains("<parameter=")
1064 || lower.contains("</function>")
1065 || lower.contains("</parameter>")
1066 {
1067 return true;
1068 }
1069 if path.contains('\n') || path.contains('\r') {
1070 return true;
1071 }
1072 if path.contains('*') {
1073 return true;
1074 }
1075 if path.contains('?') {
1078 let trimmed = path.trim();
1079 let is_windows_verbatim = trimmed.starts_with("\\\\?\\") || trimmed.starts_with("//?/");
1080 if !is_windows_verbatim {
1081 return true;
1082 }
1083 }
1084 false
1085}
1086
1087fn is_malformed_tool_pattern_token(pattern: &str) -> bool {
1088 let lower = pattern.to_ascii_lowercase();
1089 if lower.contains("<tool_call")
1090 || lower.contains("</tool_call")
1091 || lower.contains("<function=")
1092 || lower.contains("<parameter=")
1093 || lower.contains("</function>")
1094 || lower.contains("</parameter>")
1095 {
1096 return true;
1097 }
1098 if pattern.contains('\n') || pattern.contains('\r') {
1099 return true;
1100 }
1101 false
1102}
1103
1104struct WriteTool;
1107#[async_trait]
1108impl Tool for WriteTool {
1109 fn schema(&self) -> ToolSchema {
1110 tool_schema_with_capabilities(
1111 "write",
1112 "Write file contents",
1113 json!({
1114 "type":"object",
1115 "properties":{
1116 "path":{"type":"string"},
1117 "content":{"type":"string"},
1118 "allow_empty":{"type":"boolean"}
1119 },
1120 "required":["path", "content"]
1121 }),
1122 workspace_write_capabilities(),
1123 )
1124 }
1125 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1126 let path = args["path"].as_str().unwrap_or("").trim();
1127 let content = args["content"].as_str();
1128 let allow_empty = args
1129 .get("allow_empty")
1130 .and_then(|v| v.as_bool())
1131 .unwrap_or(false);
1132 let Some(path_buf) = resolve_tool_path(path, &args) else {
1133 return Ok(sandbox_path_denied_result(path, &args));
1134 };
1135 let Some(content) = content else {
1136 return Ok(ToolResult {
1137 output: "write requires `content`".to_string(),
1138 metadata: json!({"ok": false, "reason": "missing_content", "path": path}),
1139 });
1140 };
1141 if content.is_empty() && !allow_empty {
1142 return Ok(ToolResult {
1143 output: "write requires non-empty `content` (or set allow_empty=true)".to_string(),
1144 metadata: json!({"ok": false, "reason": "empty_content", "path": path}),
1145 });
1146 }
1147 if let Some(parent) = path_buf.parent() {
1148 if !parent.as_os_str().is_empty() {
1149 fs::create_dir_all(parent).await?;
1150 }
1151 }
1152 fs::write(&path_buf, content).await?;
1153 Ok(ToolResult {
1154 output: "ok".to_string(),
1155 metadata: json!({"path": path_buf.to_string_lossy()}),
1156 })
1157 }
1158}
1159
1160struct EditTool;
1161#[async_trait]
1162impl Tool for EditTool {
1163 fn schema(&self) -> ToolSchema {
1164 tool_schema_with_capabilities(
1165 "edit",
1166 "String replacement edit",
1167 json!({
1168 "type":"object",
1169 "properties":{
1170 "path":{"type":"string"},
1171 "old":{"type":"string"},
1172 "new":{"type":"string"}
1173 },
1174 "required":["path", "old", "new"]
1175 }),
1176 workspace_write_capabilities(),
1177 )
1178 }
1179 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1180 let path = args["path"].as_str().unwrap_or("");
1181 let old = args["old"].as_str().unwrap_or("");
1182 let new = args["new"].as_str().unwrap_or("");
1183 let Some(path_buf) = resolve_tool_path(path, &args) else {
1184 return Ok(sandbox_path_denied_result(path, &args));
1185 };
1186 let content = fs::read_to_string(&path_buf).await.unwrap_or_default();
1187 let updated = content.replace(old, new);
1188 fs::write(&path_buf, updated).await?;
1189 Ok(ToolResult {
1190 output: "ok".to_string(),
1191 metadata: json!({"path": path_buf.to_string_lossy()}),
1192 })
1193 }
1194}
1195
1196struct GlobTool;
1197
1198fn normalize_recursive_wildcard_pattern(pattern: &str) -> Option<String> {
1199 let mut changed = false;
1200 let normalized = pattern
1201 .split('/')
1202 .flat_map(|component| {
1203 if let Some(tail) = component.strip_prefix("**") {
1204 if !tail.is_empty() {
1205 changed = true;
1206 let normalized_tail = if tail.starts_with('.') || tail.starts_with('{') {
1207 format!("*{tail}")
1208 } else {
1209 tail.to_string()
1210 };
1211 return vec!["**".to_string(), normalized_tail];
1212 }
1213 }
1214 vec![component.to_string()]
1215 })
1216 .collect::<Vec<_>>()
1217 .join("/");
1218 changed.then_some(normalized)
1219}
1220
1221#[async_trait]
1222impl Tool for GlobTool {
1223 fn schema(&self) -> ToolSchema {
1224 tool_schema_with_capabilities(
1225 "glob",
1226 "Find files by glob",
1227 json!({"type":"object","properties":{"pattern":{"type":"string"}}}),
1228 workspace_search_capabilities(),
1229 )
1230 }
1231 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1232 let pattern = args["pattern"].as_str().unwrap_or("*");
1233 if pattern.contains("..") {
1234 return Ok(ToolResult {
1235 output: "pattern denied by sandbox policy".to_string(),
1236 metadata: json!({"pattern": pattern}),
1237 });
1238 }
1239 if is_malformed_tool_pattern_token(pattern) {
1240 return Ok(ToolResult {
1241 output: "pattern denied by sandbox policy".to_string(),
1242 metadata: json!({"pattern": pattern}),
1243 });
1244 }
1245 let workspace_root = workspace_root_from_args(&args);
1246 let effective_cwd = effective_cwd_from_args(&args);
1247 let scoped_pattern = if Path::new(pattern).is_absolute() {
1248 pattern.to_string()
1249 } else {
1250 effective_cwd.join(pattern).to_string_lossy().to_string()
1251 };
1252 let mut files = Vec::new();
1253 let mut effective_pattern = scoped_pattern.clone();
1254 let paths = match glob::glob(&scoped_pattern) {
1255 Ok(paths) => paths,
1256 Err(err) => {
1257 if let Some(normalized) = normalize_recursive_wildcard_pattern(&scoped_pattern) {
1258 if let Ok(paths) = glob::glob(&normalized) {
1259 effective_pattern = normalized;
1260 paths
1261 } else {
1262 return Err(err.into());
1263 }
1264 } else {
1265 return Err(err.into());
1266 }
1267 }
1268 };
1269 for path in paths.flatten() {
1270 if is_discovery_ignored_path(&path) {
1271 continue;
1272 }
1273 if let Some(root) = workspace_root.as_ref() {
1274 if !is_within_workspace_root(&path, root) {
1275 continue;
1276 }
1277 }
1278 files.push(path.display().to_string());
1279 if files.len() >= 100 {
1280 break;
1281 }
1282 }
1283 Ok(ToolResult {
1284 output: files.join("\n"),
1285 metadata: json!({
1286 "count": files.len(),
1287 "effective_cwd": effective_cwd,
1288 "workspace_root": workspace_root,
1289 "pattern": pattern,
1290 "effective_pattern": effective_pattern
1291 }),
1292 })
1293 }
1294}
1295
1296fn is_discovery_ignored_path(path: &Path) -> bool {
1297 let components: Vec<_> = path.components().collect();
1298 for (idx, component) in components.iter().enumerate() {
1299 if component.as_os_str() == ".tandem" {
1300 let next = components
1301 .get(idx + 1)
1302 .map(|component| component.as_os_str());
1303 return next != Some(std::ffi::OsStr::new("artifacts"));
1304 }
1305 }
1306 false
1307}
1308
1309struct GrepTool;
1310
1311#[derive(Debug, Clone)]
1312struct GrepHit {
1313 path: String,
1314 line: usize,
1315 text: String,
1316 ordinal: usize,
1317}
1318
1319fn grep_hit_to_value(hit: &GrepHit) -> Value {
1320 json!({
1321 "path": hit.path,
1322 "line": hit.line,
1323 "text": hit.text,
1324 "ordinal": hit.ordinal,
1325 })
1326}
1327
1328fn emit_grep_progress_chunk(
1329 progress: Option<&SharedToolProgressSink>,
1330 tool: &str,
1331 hits: &[GrepHit],
1332) {
1333 let Some(progress) = progress else {
1334 return;
1335 };
1336 if hits.is_empty() {
1337 return;
1338 }
1339 progress.publish(ToolProgressEvent::new(
1340 "tool.search.chunk",
1341 json!({
1342 "tool": tool,
1343 "hits": hits.iter().map(grep_hit_to_value).collect::<Vec<_>>(),
1344 }),
1345 ));
1346}
1347
1348fn emit_grep_progress_done(
1349 progress: Option<&SharedToolProgressSink>,
1350 tool: &str,
1351 path: &Path,
1352 total_hits: usize,
1353 truncated: bool,
1354 cancelled: bool,
1355) {
1356 let Some(progress) = progress else {
1357 return;
1358 };
1359 progress.publish(ToolProgressEvent::new(
1360 "tool.search.done",
1361 json!({
1362 "tool": tool,
1363 "path": path.to_string_lossy(),
1364 "count": total_hits,
1365 "truncated": truncated,
1366 "cancelled": cancelled,
1367 }),
1368 ));
1369}
1370
1371struct GrepSearchState {
1372 hits: Mutex<Vec<GrepHit>>,
1373 hit_count: AtomicUsize,
1374 stop: AtomicBool,
1375 cancel: CancellationToken,
1376 limit: usize,
1377 chunk_size: usize,
1378 progress: Option<SharedToolProgressSink>,
1379}
1380
1381impl GrepSearchState {
1382 fn new(
1383 cancel: CancellationToken,
1384 limit: usize,
1385 chunk_size: usize,
1386 progress: Option<SharedToolProgressSink>,
1387 ) -> Self {
1388 Self {
1389 hits: Mutex::new(Vec::new()),
1390 hit_count: AtomicUsize::new(0),
1391 stop: AtomicBool::new(false),
1392 cancel,
1393 limit,
1394 chunk_size,
1395 progress,
1396 }
1397 }
1398
1399 fn should_stop(&self) -> bool {
1400 self.stop.load(AtomicOrdering::Acquire) || self.cancel.is_cancelled()
1401 }
1402
1403 fn reserve_hit(&self) -> Option<usize> {
1404 if self.should_stop() {
1405 return None;
1406 }
1407 match self.hit_count.fetch_update(
1408 AtomicOrdering::AcqRel,
1409 AtomicOrdering::Acquire,
1410 |current| (current < self.limit).then_some(current + 1),
1411 ) {
1412 Ok(previous) => {
1413 let ordinal = previous + 1;
1414 if ordinal >= self.limit {
1415 self.stop.store(true, AtomicOrdering::Release);
1416 }
1417 Some(ordinal)
1418 }
1419 Err(_) => {
1420 self.stop.store(true, AtomicOrdering::Release);
1421 None
1422 }
1423 }
1424 }
1425
1426 fn push_hit(&self, hit: GrepHit) {
1427 if let Ok(mut hits) = self.hits.lock() {
1428 hits.push(hit);
1429 }
1430 }
1431
1432 fn sorted_hits(&self) -> Vec<GrepHit> {
1433 let mut hits = self
1434 .hits
1435 .lock()
1436 .map(|hits| hits.clone())
1437 .unwrap_or_default();
1438 hits.sort_by(|a, b| {
1439 a.path
1440 .cmp(&b.path)
1441 .then_with(|| a.line.cmp(&b.line))
1442 .then_with(|| a.text.cmp(&b.text))
1443 .then_with(|| a.ordinal.cmp(&b.ordinal))
1444 });
1445 hits
1446 }
1447}
1448
1449struct GrepParallelVisitorBuilder {
1450 matcher: Arc<RegexMatcher>,
1451 state: Arc<GrepSearchState>,
1452 tool: String,
1453}
1454
1455struct GrepParallelVisitor {
1456 matcher: Arc<RegexMatcher>,
1457 state: Arc<GrepSearchState>,
1458 searcher: grep_searcher::Searcher,
1459 tool: String,
1460}
1461
1462impl<'s> ParallelVisitorBuilder<'s> for GrepParallelVisitorBuilder {
1463 fn build(&mut self) -> Box<dyn ParallelVisitor + 's> {
1464 Box::new(GrepParallelVisitor {
1465 matcher: Arc::clone(&self.matcher),
1466 state: Arc::clone(&self.state),
1467 searcher: build_grep_searcher(),
1468 tool: self.tool.clone(),
1469 })
1470 }
1471}
1472
1473impl ParallelVisitor for GrepParallelVisitor {
1474 fn visit(&mut self, entry: Result<ignore::DirEntry, ignore::Error>) -> WalkState {
1475 if self.state.should_stop() {
1476 return WalkState::Quit;
1477 }
1478 let Ok(entry) = entry else {
1479 return WalkState::Continue;
1480 };
1481 if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
1482 return WalkState::Continue;
1483 }
1484 let path = entry.path();
1485 if is_discovery_ignored_path(path) {
1486 return WalkState::Continue;
1487 }
1488 let Ok(file) = std::fs::File::open(path) else {
1489 return WalkState::Continue;
1490 };
1491 let path_display = path.display().to_string();
1492 let state = Arc::clone(&self.state);
1493 let progress = state.progress.clone();
1494 let tool = self.tool.clone();
1495 let mut pending_chunk = Vec::with_capacity(state.chunk_size);
1496 let _ = self.searcher.search_file(
1497 &*self.matcher,
1498 &file,
1499 Lossy(|line_number, line| {
1500 if state.should_stop() {
1501 return Ok(false);
1502 }
1503 let Some(ordinal) = state.reserve_hit() else {
1504 return Ok(false);
1505 };
1506 let line = line.trim_end_matches(['\r', '\n']);
1507 let hit = GrepHit {
1508 path: path_display.clone(),
1509 line: line_number as usize,
1510 text: line.to_string(),
1511 ordinal,
1512 };
1513 state.push_hit(hit.clone());
1514 pending_chunk.push(hit);
1515 if pending_chunk.len() >= state.chunk_size {
1516 emit_grep_progress_chunk(progress.as_ref(), &tool, &pending_chunk);
1517 pending_chunk.clear();
1518 }
1519 if state.should_stop() {
1520 return Ok(false);
1521 }
1522 Ok(true)
1523 }),
1524 );
1525 emit_grep_progress_chunk(progress.as_ref(), &tool, &pending_chunk);
1526 if state.should_stop() {
1527 WalkState::Quit
1528 } else {
1529 WalkState::Continue
1530 }
1531 }
1532}
1533
1534fn build_grep_matcher(pattern: &str) -> anyhow::Result<RegexMatcher> {
1535 let matcher = RegexMatcherBuilder::new()
1536 .line_terminator(Some(b'\n'))
1537 .build(pattern);
1538 match matcher {
1539 Ok(matcher) => Ok(matcher),
1540 Err(_) => RegexMatcherBuilder::new()
1541 .build(pattern)
1542 .map_err(|err| anyhow!(err.to_string())),
1543 }
1544}
1545
1546fn build_grep_searcher() -> grep_searcher::Searcher {
1547 let mut builder = SearcherBuilder::new();
1548 builder
1549 .line_number(true)
1550 .memory_map(unsafe { MmapChoice::auto() })
1552 .binary_detection(BinaryDetection::quit(b'\0'))
1553 .bom_sniffing(false)
1554 .line_terminator(LineTerminator::byte(b'\n'));
1555 builder.build()
1556}
1557
1558#[async_trait]
1559impl Tool for GrepTool {
1560 fn schema(&self) -> ToolSchema {
1561 tool_schema_with_capabilities(
1562 "grep",
1563 "Regex search in files",
1564 json!({"type":"object","properties":{"pattern":{"type":"string"},"path":{"type":"string"}}}),
1565 workspace_search_capabilities(),
1566 )
1567 }
1568 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1569 self.execute_with_cancel(args, CancellationToken::new())
1570 .await
1571 }
1572
1573 async fn execute_with_cancel(
1574 &self,
1575 args: Value,
1576 cancel: CancellationToken,
1577 ) -> anyhow::Result<ToolResult> {
1578 self.execute_with_progress(args, cancel, None).await
1579 }
1580
1581 async fn execute_with_progress(
1582 &self,
1583 args: Value,
1584 cancel: CancellationToken,
1585 progress: Option<SharedToolProgressSink>,
1586 ) -> anyhow::Result<ToolResult> {
1587 let pattern = args["pattern"].as_str().unwrap_or("");
1588 let root = args["path"].as_str().unwrap_or(".");
1589 let Some(root_path) = resolve_walk_root(root, &args) else {
1590 return Ok(sandbox_path_denied_result(root, &args));
1591 };
1592 let matcher = build_grep_matcher(pattern)?;
1593 let state = Arc::new(GrepSearchState::new(
1594 cancel.clone(),
1595 100,
1596 8,
1597 progress.clone(),
1598 ));
1599 let mut builder = GrepParallelVisitorBuilder {
1600 matcher: Arc::new(matcher),
1601 state: Arc::clone(&state),
1602 tool: "grep".to_string(),
1603 };
1604 WalkBuilder::new(&root_path)
1605 .build_parallel()
1606 .visit(&mut builder);
1607 let out = state.sorted_hits();
1608 let limit_reached = out.len() >= 100;
1609 emit_grep_progress_done(
1610 progress.as_ref(),
1611 "grep",
1612 &root_path,
1613 out.len(),
1614 limit_reached,
1615 cancel.is_cancelled(),
1616 );
1617 Ok(ToolResult {
1618 output: out
1619 .iter()
1620 .map(|hit| format!("{}:{}:{}", hit.path, hit.line, hit.text))
1621 .collect::<Vec<_>>()
1622 .join("\n"),
1623 metadata: json!({
1624 "count": out.len(),
1625 "path": root_path.to_string_lossy(),
1626 "truncated": limit_reached,
1627 "cancelled": cancel.is_cancelled(),
1628 "streaming": progress.is_some()
1629 }),
1630 })
1631 }
1632}
1633
1634struct WebFetchTool;
1635#[async_trait]
1636impl Tool for WebFetchTool {
1637 fn schema(&self) -> ToolSchema {
1638 tool_schema_with_capabilities(
1639 "webfetch",
1640 "Fetch URL content and return a structured markdown document",
1641 json!({
1642 "type":"object",
1643 "properties":{
1644 "url":{"type":"string"},
1645 "mode":{"type":"string"},
1646 "return":{"type":"string"},
1647 "max_bytes":{"type":"integer"},
1648 "timeout_ms":{"type":"integer"},
1649 "max_redirects":{"type":"integer"}
1650 }
1651 }),
1652 web_fetch_capabilities(),
1653 )
1654 }
1655 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1656 let url = args["url"].as_str().unwrap_or("").trim();
1657 if url.is_empty() {
1658 return Ok(ToolResult {
1659 output: "url is required".to_string(),
1660 metadata: json!({"url": url}),
1661 });
1662 }
1663 let mode = args["mode"].as_str().unwrap_or("auto");
1664 let return_mode = args["return"].as_str().unwrap_or("markdown");
1665 let timeout_ms = args["timeout_ms"]
1666 .as_u64()
1667 .unwrap_or(15_000)
1668 .clamp(1_000, 120_000);
1669 let max_bytes = args["max_bytes"].as_u64().unwrap_or(500_000).min(5_000_000) as usize;
1670 let max_redirects = args["max_redirects"].as_u64().unwrap_or(5).min(20) as usize;
1671
1672 let started = std::time::Instant::now();
1673 let fetched = fetch_url_with_limits(url, timeout_ms, max_bytes, max_redirects).await?;
1674 let raw = String::from_utf8_lossy(&fetched.buffer).to_string();
1675
1676 let cleaned = strip_html_noise(&raw);
1677 let title = extract_title(&cleaned).unwrap_or_default();
1678 let canonical = extract_canonical(&cleaned);
1679 let links = extract_links(&cleaned);
1680
1681 let markdown = if fetched.content_type.contains("html") || fetched.content_type.is_empty() {
1682 html2md::parse_html(&cleaned)
1683 } else {
1684 cleaned.clone()
1685 };
1686 let text = markdown_to_text(&markdown);
1687
1688 let markdown_out = if return_mode == "text" {
1689 String::new()
1690 } else {
1691 markdown
1692 };
1693 let text_out = if return_mode == "markdown" {
1694 String::new()
1695 } else {
1696 text
1697 };
1698
1699 let raw_chars = raw.chars().count();
1700 let markdown_chars = markdown_out.chars().count();
1701 let reduction_pct = if raw_chars == 0 {
1702 0.0
1703 } else {
1704 ((raw_chars.saturating_sub(markdown_chars)) as f64 / raw_chars as f64) * 100.0
1705 };
1706
1707 let output = json!({
1708 "url": url,
1709 "final_url": fetched.final_url,
1710 "title": title,
1711 "content_type": fetched.content_type,
1712 "markdown": markdown_out,
1713 "text": text_out,
1714 "links": links,
1715 "meta": {
1716 "canonical": canonical,
1717 "mode": mode
1718 },
1719 "stats": {
1720 "bytes_in": fetched.buffer.len(),
1721 "bytes_out": markdown_chars,
1722 "raw_chars": raw_chars,
1723 "markdown_chars": markdown_chars,
1724 "reduction_pct": reduction_pct,
1725 "elapsed_ms": started.elapsed().as_millis(),
1726 "truncated": fetched.truncated
1727 }
1728 });
1729
1730 Ok(ToolResult {
1731 output: serde_json::to_string_pretty(&output)?,
1732 metadata: json!({
1733 "url": url,
1734 "final_url": fetched.final_url,
1735 "content_type": fetched.content_type,
1736 "truncated": fetched.truncated
1737 }),
1738 })
1739 }
1740}
1741
1742struct WebFetchHtmlTool;
1743#[async_trait]
1744impl Tool for WebFetchHtmlTool {
1745 fn schema(&self) -> ToolSchema {
1746 tool_schema_with_capabilities(
1747 "webfetch_html",
1748 "Fetch URL and return raw HTML content",
1749 json!({
1750 "type":"object",
1751 "properties":{
1752 "url":{"type":"string"},
1753 "max_bytes":{"type":"integer"},
1754 "timeout_ms":{"type":"integer"},
1755 "max_redirects":{"type":"integer"}
1756 }
1757 }),
1758 web_fetch_capabilities(),
1759 )
1760 }
1761 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1762 let url = args["url"].as_str().unwrap_or("").trim();
1763 if url.is_empty() {
1764 return Ok(ToolResult {
1765 output: "url is required".to_string(),
1766 metadata: json!({"url": url}),
1767 });
1768 }
1769 let timeout_ms = args["timeout_ms"]
1770 .as_u64()
1771 .unwrap_or(15_000)
1772 .clamp(1_000, 120_000);
1773 let max_bytes = args["max_bytes"].as_u64().unwrap_or(500_000).min(5_000_000) as usize;
1774 let max_redirects = args["max_redirects"].as_u64().unwrap_or(5).min(20) as usize;
1775
1776 let started = std::time::Instant::now();
1777 let fetched = fetch_url_with_limits(url, timeout_ms, max_bytes, max_redirects).await?;
1778 let output = String::from_utf8_lossy(&fetched.buffer).to_string();
1779
1780 Ok(ToolResult {
1781 output,
1782 metadata: json!({
1783 "url": url,
1784 "final_url": fetched.final_url,
1785 "content_type": fetched.content_type,
1786 "truncated": fetched.truncated,
1787 "bytes_in": fetched.buffer.len(),
1788 "elapsed_ms": started.elapsed().as_millis()
1789 }),
1790 })
1791 }
1792}
1793
1794struct FetchedResponse {
1795 final_url: String,
1796 content_type: String,
1797 buffer: Vec<u8>,
1798 truncated: bool,
1799}
1800
1801async fn fetch_url_with_limits(
1802 url: &str,
1803 timeout_ms: u64,
1804 max_bytes: usize,
1805 max_redirects: usize,
1806) -> anyhow::Result<FetchedResponse> {
1807 let client = reqwest::Client::builder()
1808 .timeout(std::time::Duration::from_millis(timeout_ms))
1809 .redirect(reqwest::redirect::Policy::limited(max_redirects))
1810 .build()?;
1811
1812 let res = client
1813 .get(url)
1814 .header(
1815 "Accept",
1816 "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
1817 )
1818 .send()
1819 .await?;
1820 let final_url = res.url().to_string();
1821 let content_type = res
1822 .headers()
1823 .get("content-type")
1824 .and_then(|v| v.to_str().ok())
1825 .unwrap_or("")
1826 .to_string();
1827
1828 let mut stream = res.bytes_stream();
1829 let mut buffer: Vec<u8> = Vec::new();
1830 let mut truncated = false;
1831 while let Some(chunk) = stream.next().await {
1832 let chunk = chunk?;
1833 if buffer.len() + chunk.len() > max_bytes {
1834 let remaining = max_bytes.saturating_sub(buffer.len());
1835 buffer.extend_from_slice(&chunk[..remaining]);
1836 truncated = true;
1837 break;
1838 }
1839 buffer.extend_from_slice(&chunk);
1840 }
1841
1842 Ok(FetchedResponse {
1843 final_url,
1844 content_type,
1845 buffer,
1846 truncated,
1847 })
1848}
1849
1850fn strip_html_noise(input: &str) -> String {
1851 let script_re = Regex::new(r"(?is)<script[^>]*>.*?</script>").unwrap();
1852 let style_re = Regex::new(r"(?is)<style[^>]*>.*?</style>").unwrap();
1853 let noscript_re = Regex::new(r"(?is)<noscript[^>]*>.*?</noscript>").unwrap();
1854 let cleaned = script_re.replace_all(input, "");
1855 let cleaned = style_re.replace_all(&cleaned, "");
1856 let cleaned = noscript_re.replace_all(&cleaned, "");
1857 cleaned.to_string()
1858}
1859
1860fn extract_title(input: &str) -> Option<String> {
1861 let title_re = Regex::new(r"(?is)<title[^>]*>(.*?)</title>").ok()?;
1862 let caps = title_re.captures(input)?;
1863 let raw = caps.get(1)?.as_str();
1864 let tag_re = Regex::new(r"(?is)<[^>]+>").ok()?;
1865 Some(tag_re.replace_all(raw, "").trim().to_string())
1866}
1867
1868fn extract_canonical(input: &str) -> Option<String> {
1869 let canon_re =
1870 Regex::new(r#"(?is)<link[^>]*rel=["']canonical["'][^>]*href=["']([^"']+)["'][^>]*>"#)
1871 .ok()?;
1872 let caps = canon_re.captures(input)?;
1873 Some(caps.get(1)?.as_str().trim().to_string())
1874}
1875
1876fn extract_links(input: &str) -> Vec<Value> {
1877 let link_re = Regex::new(r#"(?is)<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>"#).unwrap();
1878 let tag_re = Regex::new(r"(?is)<[^>]+>").unwrap();
1879 let mut out = Vec::new();
1880 for caps in link_re.captures_iter(input).take(200) {
1881 let href = caps.get(1).map(|m| m.as_str()).unwrap_or("").trim();
1882 let raw_text = caps.get(2).map(|m| m.as_str()).unwrap_or("");
1883 let text = tag_re.replace_all(raw_text, "");
1884 if !href.is_empty() {
1885 out.push(json!({
1886 "text": text.trim(),
1887 "href": href
1888 }));
1889 }
1890 }
1891 out
1892}
1893
1894fn markdown_to_text(input: &str) -> String {
1895 let code_block_re = Regex::new(r"(?s)```.*?```").unwrap();
1896 let inline_code_re = Regex::new(r"`[^`]*`").unwrap();
1897 let link_re = Regex::new(r"\[([^\]]+)\]\([^)]+\)").unwrap();
1898 let emphasis_re = Regex::new(r"[*_~]+").unwrap();
1899 let cleaned = code_block_re.replace_all(input, "");
1900 let cleaned = inline_code_re.replace_all(&cleaned, "");
1901 let cleaned = link_re.replace_all(&cleaned, "$1");
1902 let cleaned = emphasis_re.replace_all(&cleaned, "");
1903 let cleaned = cleaned.replace('#', "");
1904 let whitespace_re = Regex::new(r"\n{3,}").unwrap();
1905 let cleaned = whitespace_re.replace_all(&cleaned, "\n\n");
1906 cleaned.trim().to_string()
1907}
1908
1909struct McpDebugTool;
1910#[async_trait]
1911impl Tool for McpDebugTool {
1912 fn schema(&self) -> ToolSchema {
1913 tool_schema(
1914 "mcp_debug",
1915 "Call an MCP tool and return the raw response",
1916 json!({
1917 "type":"object",
1918 "properties":{
1919 "url":{"type":"string"},
1920 "tool":{"type":"string"},
1921 "args":{"type":"object"},
1922 "headers":{"type":"object"},
1923 "timeout_ms":{"type":"integer"},
1924 "max_bytes":{"type":"integer"}
1925 }
1926 }),
1927 )
1928 }
1929 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1930 let url = args["url"].as_str().unwrap_or("").trim();
1931 let tool = args["tool"].as_str().unwrap_or("").trim();
1932 if url.is_empty() || tool.is_empty() {
1933 return Ok(ToolResult {
1934 output: "url and tool are required".to_string(),
1935 metadata: json!({"url": url, "tool": tool}),
1936 });
1937 }
1938 let timeout_ms = args["timeout_ms"]
1939 .as_u64()
1940 .unwrap_or(15_000)
1941 .clamp(1_000, 120_000);
1942 let max_bytes = args["max_bytes"].as_u64().unwrap_or(200_000).min(5_000_000) as usize;
1943 let request_args = args.get("args").cloned().unwrap_or_else(|| json!({}));
1944
1945 #[derive(serde::Serialize)]
1946 struct McpCallRequest {
1947 jsonrpc: String,
1948 id: u32,
1949 method: String,
1950 params: McpCallParams,
1951 }
1952
1953 #[derive(serde::Serialize)]
1954 struct McpCallParams {
1955 name: String,
1956 arguments: Value,
1957 }
1958
1959 let request = McpCallRequest {
1960 jsonrpc: "2.0".to_string(),
1961 id: 1,
1962 method: "tools/call".to_string(),
1963 params: McpCallParams {
1964 name: tool.to_string(),
1965 arguments: request_args,
1966 },
1967 };
1968
1969 let client = reqwest::Client::builder()
1970 .timeout(std::time::Duration::from_millis(timeout_ms))
1971 .build()?;
1972
1973 let mut builder = client
1974 .post(url)
1975 .header("Content-Type", "application/json")
1976 .header("Accept", "application/json, text/event-stream");
1977
1978 if let Some(headers) = args.get("headers").and_then(|v| v.as_object()) {
1979 for (key, value) in headers {
1980 if let Some(value) = value.as_str() {
1981 builder = builder.header(key, value);
1982 }
1983 }
1984 }
1985
1986 let res = builder.json(&request).send().await?;
1987 let status = res.status().as_u16();
1988
1989 let mut response_headers = serde_json::Map::new();
1990 for (key, value) in res.headers().iter() {
1991 if let Ok(value) = value.to_str() {
1992 response_headers.insert(key.to_string(), Value::String(value.to_string()));
1993 }
1994 }
1995
1996 let mut stream = res.bytes_stream();
1997 let mut buffer: Vec<u8> = Vec::new();
1998 let mut truncated = false;
1999
2000 while let Some(chunk) = stream.next().await {
2001 let chunk = chunk?;
2002 if buffer.len() + chunk.len() > max_bytes {
2003 let remaining = max_bytes.saturating_sub(buffer.len());
2004 buffer.extend_from_slice(&chunk[..remaining]);
2005 truncated = true;
2006 break;
2007 }
2008 buffer.extend_from_slice(&chunk);
2009 }
2010
2011 let body = String::from_utf8_lossy(&buffer).to_string();
2012 let output = json!({
2013 "status": status,
2014 "headers": response_headers,
2015 "body": body,
2016 "truncated": truncated,
2017 "bytes": buffer.len()
2018 });
2019
2020 Ok(ToolResult {
2021 output: serde_json::to_string_pretty(&output)?,
2022 metadata: json!({
2023 "url": url,
2024 "tool": tool,
2025 "timeout_ms": timeout_ms,
2026 "max_bytes": max_bytes
2027 }),
2028 })
2029 }
2030}
2031
2032struct WebSearchTool {
2033 backend: SearchBackend,
2034}
2035#[async_trait]
2036impl Tool for WebSearchTool {
2037 fn schema(&self) -> ToolSchema {
2038 tool_schema_with_capabilities(
2039 "websearch",
2040 self.backend.schema_description(),
2041 json!({
2042 "type": "object",
2043 "properties": {
2044 "query": { "type": "string" },
2045 "limit": { "type": "integer" }
2046 },
2047 "required": ["query"]
2048 }),
2049 web_fetch_capabilities(),
2050 )
2051 }
2052 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
2053 let query = extract_websearch_query(&args).unwrap_or_default();
2054 let query_source = args
2055 .get("__query_source")
2056 .and_then(|v| v.as_str())
2057 .map(|s| s.to_string())
2058 .unwrap_or_else(|| {
2059 if query.is_empty() {
2060 "missing".to_string()
2061 } else {
2062 "tool_args".to_string()
2063 }
2064 });
2065 let query_hash = if query.is_empty() {
2066 None
2067 } else {
2068 Some(stable_hash(&query))
2069 };
2070 if query.is_empty() {
2071 tracing::warn!("WebSearchTool missing query. Args: {}", args);
2072 return Ok(ToolResult {
2073 output: format!("missing query. Received args: {}", args),
2074 metadata: json!({
2075 "count": 0,
2076 "error": "missing_query",
2077 "query_source": query_source,
2078 "query_hash": query_hash,
2079 "loop_guard_triggered": false
2080 }),
2081 });
2082 }
2083 let num_results = extract_websearch_limit(&args).unwrap_or(8);
2084 let outcome = execute_websearch_backend(&self.backend, &query, num_results).await?;
2085 let configured_backend = self.backend.name();
2086 let backend_used = outcome
2087 .backend_used
2088 .as_deref()
2089 .unwrap_or(configured_backend);
2090 let mut metadata = json!({
2091 "query": query,
2092 "query_source": query_source,
2093 "query_hash": query_hash,
2094 "backend": backend_used,
2095 "configured_backend": configured_backend,
2096 "attempted_backends": outcome.attempted_backends,
2097 "loop_guard_triggered": false,
2098 "count": outcome.results.len(),
2099 "partial": outcome.partial
2100 });
2101 if let Some(kind) = outcome.unavailable_kind {
2102 metadata["error"] = json!(kind);
2103 }
2104
2105 if let Some(message) = outcome.unavailable_message {
2106 return Ok(ToolResult {
2107 output: message,
2108 metadata: metadata,
2109 });
2110 }
2111
2112 let output = json!({
2113 "query": query,
2114 "backend": backend_used,
2115 "configured_backend": configured_backend,
2116 "attempted_backends": metadata["attempted_backends"],
2117 "result_count": outcome.results.len(),
2118 "partial": outcome.partial,
2119 "results": outcome.results,
2120 });
2121
2122 Ok(ToolResult {
2123 output: serde_json::to_string_pretty(&output)?,
2124 metadata,
2125 })
2126 }
2127}
2128
2129struct SearchExecutionOutcome {
2130 results: Vec<SearchResultEntry>,
2131 partial: bool,
2132 unavailable_message: Option<String>,
2133 unavailable_kind: Option<&'static str>,
2134 backend_used: Option<String>,
2135 attempted_backends: Vec<String>,
2136}
2137
2138async fn execute_websearch_backend(
2139 backend: &SearchBackend,
2140 query: &str,
2141 num_results: u64,
2142) -> anyhow::Result<SearchExecutionOutcome> {
2143 match backend {
2144 SearchBackend::Auto { backends } => {
2145 let mut attempted_backends = Vec::new();
2146 let mut best_unavailable: Option<SearchExecutionOutcome> = None;
2147
2148 for candidate in backends {
2149 let mut outcome =
2150 execute_websearch_backend_once(candidate, query, num_results).await?;
2151 attempted_backends.extend(outcome.attempted_backends.iter().cloned());
2152 if outcome.unavailable_kind.is_none() {
2153 if outcome.backend_used.is_none() {
2154 outcome.backend_used = Some(candidate.name().to_string());
2155 }
2156 outcome.attempted_backends = attempted_backends;
2157 return Ok(outcome);
2158 }
2159
2160 let should_replace = best_unavailable
2161 .as_ref()
2162 .map(|current| {
2163 search_unavailability_priority(outcome.unavailable_kind)
2164 > search_unavailability_priority(current.unavailable_kind)
2165 })
2166 .unwrap_or(true);
2167 outcome.attempted_backends = attempted_backends.clone();
2168 if should_replace {
2169 best_unavailable = Some(outcome);
2170 }
2171 }
2172
2173 let mut outcome = best_unavailable.unwrap_or_else(search_backend_unavailable_outcome);
2174 outcome.attempted_backends = attempted_backends;
2175 Ok(outcome)
2176 }
2177 _ => execute_websearch_backend_once(backend, query, num_results).await,
2178 }
2179}
2180
2181async fn execute_websearch_backend_once(
2182 backend: &SearchBackend,
2183 query: &str,
2184 num_results: u64,
2185) -> anyhow::Result<SearchExecutionOutcome> {
2186 match backend {
2187 SearchBackend::Disabled { reason } => Ok(SearchExecutionOutcome {
2188 results: Vec::new(),
2189 partial: false,
2190 unavailable_message: Some(format!(
2191 "Search backend is unavailable for `websearch`: {reason}"
2192 )),
2193 unavailable_kind: Some("backend_unavailable"),
2194 backend_used: Some("disabled".to_string()),
2195 attempted_backends: vec!["disabled".to_string()],
2196 }),
2197 SearchBackend::Tandem {
2198 base_url,
2199 timeout_ms,
2200 } => execute_tandem_search(base_url, *timeout_ms, query, num_results).await,
2201 SearchBackend::Searxng {
2202 base_url,
2203 engines,
2204 timeout_ms,
2205 } => {
2206 execute_searxng_search(
2207 base_url,
2208 engines.as_deref(),
2209 *timeout_ms,
2210 query,
2211 num_results,
2212 )
2213 .await
2214 }
2215 SearchBackend::Exa {
2216 api_key,
2217 timeout_ms,
2218 } => execute_exa_search(api_key, *timeout_ms, query, num_results).await,
2219 SearchBackend::Brave {
2220 api_key,
2221 timeout_ms,
2222 } => execute_brave_search(api_key, *timeout_ms, query, num_results).await,
2223 SearchBackend::Auto { .. } => unreachable!("auto backend should be handled by the wrapper"),
2224 }
2225}
2226
2227fn search_backend_unavailable_outcome() -> SearchExecutionOutcome {
2228 SearchExecutionOutcome {
2229 results: Vec::new(),
2230 partial: false,
2231 unavailable_message: Some(
2232 "Web search is currently unavailable for `websearch`.\nContinue with local file reads and note that external research could not be completed in this run."
2233 .to_string(),
2234 ),
2235 unavailable_kind: Some("backend_unavailable"),
2236 backend_used: None,
2237 attempted_backends: Vec::new(),
2238 }
2239}
2240
2241fn search_backend_authorization_required_outcome() -> SearchExecutionOutcome {
2242 SearchExecutionOutcome {
2243 results: Vec::new(),
2244 partial: false,
2245 unavailable_message: Some(
2246 "Authorization required for `websearch`.\nThis integration requires authorization before this action can run."
2247 .to_string(),
2248 ),
2249 unavailable_kind: Some("authorization_required"),
2250 backend_used: None,
2251 attempted_backends: Vec::new(),
2252 }
2253}
2254
2255fn search_backend_rate_limited_outcome(
2256 backend_name: &str,
2257 retry_after_secs: Option<u64>,
2258) -> SearchExecutionOutcome {
2259 let retry_hint = retry_after_secs
2260 .map(|value| format!("\nRetry after about {value} second(s)."))
2261 .unwrap_or_default();
2262 SearchExecutionOutcome {
2263 results: Vec::new(),
2264 partial: false,
2265 unavailable_message: Some(format!(
2266 "Web search is currently rate limited for `websearch` on the {backend_name} backend.\nContinue with local file reads and note that external research could not be completed in this run.{retry_hint}"
2267 )),
2268 unavailable_kind: Some("rate_limited"),
2269 backend_used: Some(backend_name.to_string()),
2270 attempted_backends: vec![backend_name.to_string()],
2271 }
2272}
2273
2274fn search_unavailability_priority(kind: Option<&'static str>) -> u8 {
2275 match kind {
2276 Some("authorization_required") => 3,
2277 Some("rate_limited") => 2,
2278 Some("backend_unavailable") => 1,
2279 _ => 0,
2280 }
2281}
2282
2283async fn execute_tandem_search(
2284 base_url: &str,
2285 timeout_ms: u64,
2286 query: &str,
2287 num_results: u64,
2288) -> anyhow::Result<SearchExecutionOutcome> {
2289 let client = reqwest::Client::builder()
2290 .timeout(Duration::from_millis(timeout_ms))
2291 .build()?;
2292 let endpoint = format!("{}/search", base_url.trim_end_matches('/'));
2293 let response = match client
2294 .post(&endpoint)
2295 .header("Content-Type", "application/json")
2296 .header("Accept", "application/json")
2297 .json(&json!({
2298 "query": query,
2299 "limit": num_results,
2300 }))
2301 .send()
2302 .await
2303 {
2304 Ok(response) => response,
2305 Err(_) => {
2306 let mut outcome = search_backend_unavailable_outcome();
2307 outcome.backend_used = Some("tandem".to_string());
2308 outcome.attempted_backends = vec!["tandem".to_string()];
2309 return Ok(outcome);
2310 }
2311 };
2312 let status = response.status();
2313 if matches!(
2314 status,
2315 reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
2316 ) {
2317 let mut outcome = search_backend_authorization_required_outcome();
2318 outcome.backend_used = Some("tandem".to_string());
2319 outcome.attempted_backends = vec!["tandem".to_string()];
2320 return Ok(outcome);
2321 }
2322 if !status.is_success() {
2323 let mut outcome = search_backend_unavailable_outcome();
2324 outcome.backend_used = Some("tandem".to_string());
2325 outcome.attempted_backends = vec!["tandem".to_string()];
2326 return Ok(outcome);
2327 }
2328 let body: Value = match response.json().await {
2329 Ok(value) => value,
2330 Err(_) => {
2331 let mut outcome = search_backend_unavailable_outcome();
2332 outcome.backend_used = Some("tandem".to_string());
2333 outcome.attempted_backends = vec!["tandem".to_string()];
2334 return Ok(outcome);
2335 }
2336 };
2337 let raw_results = body
2338 .get("results")
2339 .and_then(Value::as_array)
2340 .cloned()
2341 .unwrap_or_default();
2342 let results = normalize_tandem_results(&raw_results, num_results as usize);
2343 let partial = body
2344 .get("partial")
2345 .and_then(Value::as_bool)
2346 .unwrap_or_else(|| raw_results.len() > results.len());
2347 Ok(SearchExecutionOutcome {
2348 results,
2349 partial,
2350 unavailable_message: None,
2351 unavailable_kind: None,
2352 backend_used: Some("tandem".to_string()),
2353 attempted_backends: vec!["tandem".to_string()],
2354 })
2355}
2356
2357async fn execute_searxng_search(
2358 base_url: &str,
2359 engines: Option<&str>,
2360 timeout_ms: u64,
2361 query: &str,
2362 num_results: u64,
2363) -> anyhow::Result<SearchExecutionOutcome> {
2364 let client = reqwest::Client::builder()
2365 .timeout(Duration::from_millis(timeout_ms))
2366 .build()?;
2367 let endpoint = format!("{}/search", base_url.trim_end_matches('/'));
2368 let mut params: Vec<(&str, String)> = vec![
2369 ("q", query.to_string()),
2370 ("format", "json".to_string()),
2371 ("pageno", "1".to_string()),
2372 ];
2373 if let Some(engines) = engines {
2374 params.push(("engines", engines.to_string()));
2375 }
2376 let response = client.get(&endpoint).query(¶ms).send().await?;
2377
2378 let status = response.status();
2379 if status == reqwest::StatusCode::FORBIDDEN {
2380 let mut outcome = search_backend_authorization_required_outcome();
2381 outcome.backend_used = Some("searxng".to_string());
2382 outcome.attempted_backends = vec!["searxng".to_string()];
2383 return Ok(outcome);
2384 }
2385 if !status.is_success() {
2386 let mut outcome = search_backend_unavailable_outcome();
2387 outcome.backend_used = Some("searxng".to_string());
2388 outcome.attempted_backends = vec!["searxng".to_string()];
2389 return Ok(outcome);
2390 }
2391 let status_for_error = status;
2392 let body: Value = match response.json().await {
2393 Ok(value) => value,
2394 Err(_) => {
2395 let mut outcome = search_backend_unavailable_outcome();
2396 outcome.backend_used = Some("searxng".to_string());
2397 outcome.attempted_backends = vec!["searxng".to_string()];
2398 return Ok(outcome);
2399 }
2400 };
2401 let raw_results = body
2402 .get("results")
2403 .and_then(Value::as_array)
2404 .cloned()
2405 .unwrap_or_default();
2406 let results = normalize_searxng_results(&raw_results, num_results as usize);
2407 let partial = raw_results.len() > results.len()
2408 || status_for_error == reqwest::StatusCode::PARTIAL_CONTENT;
2409 Ok(SearchExecutionOutcome {
2410 results,
2411 partial,
2412 unavailable_message: None,
2413 unavailable_kind: None,
2414 backend_used: Some("searxng".to_string()),
2415 attempted_backends: vec!["searxng".to_string()],
2416 })
2417}
2418
2419async fn execute_exa_search(
2420 api_key: &str,
2421 timeout_ms: u64,
2422 query: &str,
2423 num_results: u64,
2424) -> anyhow::Result<SearchExecutionOutcome> {
2425 let client = reqwest::Client::builder()
2426 .timeout(Duration::from_millis(timeout_ms))
2427 .build()?;
2428 let response = match client
2429 .post("https://api.exa.ai/search")
2430 .header("Content-Type", "application/json")
2431 .header("Accept", "application/json")
2432 .header("x-api-key", api_key)
2433 .json(&json!({
2434 "query": query,
2435 "numResults": num_results,
2436 }))
2437 .send()
2438 .await
2439 {
2440 Ok(response) => response,
2441 Err(_) => {
2442 let mut outcome = search_backend_unavailable_outcome();
2443 outcome.backend_used = Some("exa".to_string());
2444 outcome.attempted_backends = vec!["exa".to_string()];
2445 return Ok(outcome);
2446 }
2447 };
2448 let status = response.status();
2449 if matches!(
2450 status,
2451 reqwest::StatusCode::UNAUTHORIZED
2452 | reqwest::StatusCode::FORBIDDEN
2453 | reqwest::StatusCode::PAYMENT_REQUIRED
2454 ) {
2455 let mut outcome = search_backend_authorization_required_outcome();
2456 outcome.backend_used = Some("exa".to_string());
2457 outcome.attempted_backends = vec!["exa".to_string()];
2458 return Ok(outcome);
2459 }
2460 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
2461 let retry_after_secs = response
2462 .headers()
2463 .get("retry-after")
2464 .and_then(|value| value.to_str().ok())
2465 .and_then(|value| value.trim().parse::<u64>().ok());
2466 return Ok(search_backend_rate_limited_outcome("exa", retry_after_secs));
2467 }
2468 if !status.is_success() {
2469 let mut outcome = search_backend_unavailable_outcome();
2470 outcome.backend_used = Some("exa".to_string());
2471 outcome.attempted_backends = vec!["exa".to_string()];
2472 return Ok(outcome);
2473 }
2474 let body: Value = match response.json().await {
2475 Ok(value) => value,
2476 Err(_) => {
2477 let mut outcome = search_backend_unavailable_outcome();
2478 outcome.backend_used = Some("exa".to_string());
2479 outcome.attempted_backends = vec!["exa".to_string()];
2480 return Ok(outcome);
2481 }
2482 };
2483 let raw_results = body
2484 .get("results")
2485 .and_then(Value::as_array)
2486 .cloned()
2487 .unwrap_or_default();
2488 let results = normalize_exa_results(&raw_results, num_results as usize);
2489 Ok(SearchExecutionOutcome {
2490 partial: raw_results.len() > results.len(),
2491 results,
2492 unavailable_message: None,
2493 unavailable_kind: None,
2494 backend_used: Some("exa".to_string()),
2495 attempted_backends: vec!["exa".to_string()],
2496 })
2497}
2498
2499async fn execute_brave_search(
2500 api_key: &str,
2501 timeout_ms: u64,
2502 query: &str,
2503 num_results: u64,
2504) -> anyhow::Result<SearchExecutionOutcome> {
2505 let client = reqwest::Client::builder()
2506 .timeout(Duration::from_millis(timeout_ms))
2507 .build()?;
2508 let count = num_results.to_string();
2509 let response = match client
2510 .get("https://api.search.brave.com/res/v1/web/search")
2511 .header("Accept", "application/json")
2512 .header("X-Subscription-Token", api_key)
2513 .query(&[("q", query), ("count", count.as_str())])
2514 .send()
2515 .await
2516 {
2517 Ok(response) => response,
2518 Err(error) => {
2519 tracing::warn!("brave websearch request failed: {}", error);
2520 let mut outcome = search_backend_unavailable_outcome();
2521 outcome.backend_used = Some("brave".to_string());
2522 outcome.attempted_backends = vec!["brave".to_string()];
2523 return Ok(outcome);
2524 }
2525 };
2526 let status = response.status();
2527 if matches!(
2528 status,
2529 reqwest::StatusCode::UNAUTHORIZED
2530 | reqwest::StatusCode::FORBIDDEN
2531 | reqwest::StatusCode::PAYMENT_REQUIRED
2532 ) {
2533 let mut outcome = search_backend_authorization_required_outcome();
2534 outcome.backend_used = Some("brave".to_string());
2535 outcome.attempted_backends = vec!["brave".to_string()];
2536 return Ok(outcome);
2537 }
2538 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
2539 let retry_after_secs = response
2540 .headers()
2541 .get("retry-after")
2542 .and_then(|value| value.to_str().ok())
2543 .and_then(|value| value.trim().parse::<u64>().ok());
2544 return Ok(search_backend_rate_limited_outcome(
2545 "brave",
2546 retry_after_secs,
2547 ));
2548 }
2549 if !status.is_success() {
2550 tracing::warn!("brave websearch returned non-success status: {}", status);
2551 let mut outcome = search_backend_unavailable_outcome();
2552 outcome.backend_used = Some("brave".to_string());
2553 outcome.attempted_backends = vec!["brave".to_string()];
2554 return Ok(outcome);
2555 }
2556 let body_text = match response.text().await {
2557 Ok(value) => value,
2558 Err(error) => {
2559 tracing::warn!("brave websearch body read failed: {}", error);
2560 let mut outcome = search_backend_unavailable_outcome();
2561 outcome.backend_used = Some("brave".to_string());
2562 outcome.attempted_backends = vec!["brave".to_string()];
2563 return Ok(outcome);
2564 }
2565 };
2566 let body: Value = match serde_json::from_str(&body_text) {
2567 Ok(value) => value,
2568 Err(error) => {
2569 let snippet = body_text.chars().take(200).collect::<String>();
2570 tracing::warn!(
2571 "brave websearch JSON parse failed: {} body_prefix={:?}",
2572 error,
2573 snippet
2574 );
2575 let mut outcome = search_backend_unavailable_outcome();
2576 outcome.backend_used = Some("brave".to_string());
2577 outcome.attempted_backends = vec!["brave".to_string()];
2578 return Ok(outcome);
2579 }
2580 };
2581 let raw_results = body
2582 .get("web")
2583 .and_then(|value| value.get("results"))
2584 .and_then(Value::as_array)
2585 .cloned()
2586 .unwrap_or_default();
2587 let results = normalize_brave_results(&raw_results, num_results as usize);
2588 Ok(SearchExecutionOutcome {
2589 partial: raw_results.len() > results.len(),
2590 results,
2591 unavailable_message: None,
2592 unavailable_kind: None,
2593 backend_used: Some("brave".to_string()),
2594 attempted_backends: vec!["brave".to_string()],
2595 })
2596}
2597
2598fn normalize_tandem_results(raw_results: &[Value], limit: usize) -> Vec<SearchResultEntry> {
2599 raw_results
2600 .iter()
2601 .filter_map(|entry| {
2602 let title = entry
2603 .get("title")
2604 .or_else(|| entry.get("name"))
2605 .and_then(Value::as_str)?
2606 .trim()
2607 .to_string();
2608 let url = entry.get("url").and_then(Value::as_str)?.trim().to_string();
2609 if title.is_empty() || url.is_empty() {
2610 return None;
2611 }
2612 let snippet = entry
2613 .get("snippet")
2614 .or_else(|| entry.get("content"))
2615 .or_else(|| entry.get("description"))
2616 .and_then(Value::as_str)
2617 .map(str::trim)
2618 .unwrap_or_default()
2619 .to_string();
2620 let source = entry
2621 .get("source")
2622 .or_else(|| entry.get("provider"))
2623 .and_then(Value::as_str)
2624 .map(str::trim)
2625 .filter(|value| !value.is_empty())
2626 .unwrap_or("tandem")
2627 .to_string();
2628 Some(SearchResultEntry {
2629 title,
2630 url,
2631 snippet,
2632 source,
2633 })
2634 })
2635 .take(limit)
2636 .collect()
2637}
2638
2639fn normalize_searxng_results(raw_results: &[Value], limit: usize) -> Vec<SearchResultEntry> {
2640 raw_results
2641 .iter()
2642 .filter_map(|entry| {
2643 let title = entry
2644 .get("title")
2645 .and_then(Value::as_str)?
2646 .trim()
2647 .to_string();
2648 let url = entry.get("url").and_then(Value::as_str)?.trim().to_string();
2649 if title.is_empty() || url.is_empty() {
2650 return None;
2651 }
2652 let snippet = entry
2653 .get("content")
2654 .and_then(Value::as_str)
2655 .or_else(|| entry.get("snippet").and_then(Value::as_str))
2656 .unwrap_or("")
2657 .trim()
2658 .to_string();
2659 let source = entry
2660 .get("engine")
2661 .and_then(Value::as_str)
2662 .map(|engine| format!("searxng:{engine}"))
2663 .unwrap_or_else(|| "searxng".to_string());
2664 Some(SearchResultEntry {
2665 title,
2666 url,
2667 snippet,
2668 source,
2669 })
2670 })
2671 .take(limit)
2672 .collect()
2673}
2674
2675fn normalize_exa_results(raw_results: &[Value], limit: usize) -> Vec<SearchResultEntry> {
2676 raw_results
2677 .iter()
2678 .filter_map(|entry| {
2679 let title = entry
2680 .get("title")
2681 .and_then(Value::as_str)?
2682 .trim()
2683 .to_string();
2684 let url = entry.get("url").and_then(Value::as_str)?.trim().to_string();
2685 if title.is_empty() || url.is_empty() {
2686 return None;
2687 }
2688 let snippet = entry
2689 .get("text")
2690 .and_then(Value::as_str)
2691 .or_else(|| {
2692 entry
2693 .get("highlights")
2694 .and_then(Value::as_array)
2695 .and_then(|items| items.iter().find_map(Value::as_str))
2696 })
2697 .unwrap_or("")
2698 .chars()
2699 .take(400)
2700 .collect::<String>()
2701 .trim()
2702 .to_string();
2703 Some(SearchResultEntry {
2704 title,
2705 url,
2706 snippet,
2707 source: "exa".to_string(),
2708 })
2709 })
2710 .take(limit)
2711 .collect()
2712}
2713
2714fn normalize_brave_results(raw_results: &[Value], limit: usize) -> Vec<SearchResultEntry> {
2715 raw_results
2716 .iter()
2717 .filter_map(|entry| {
2718 let title = entry
2719 .get("title")
2720 .and_then(Value::as_str)?
2721 .trim()
2722 .to_string();
2723 let url = entry.get("url").and_then(Value::as_str)?.trim().to_string();
2724 if title.is_empty() || url.is_empty() {
2725 return None;
2726 }
2727 let snippet = entry
2728 .get("description")
2729 .and_then(Value::as_str)
2730 .or_else(|| entry.get("snippet").and_then(Value::as_str))
2731 .unwrap_or("")
2732 .trim()
2733 .to_string();
2734 let source = entry
2735 .get("profile")
2736 .and_then(|value| value.get("long_name"))
2737 .and_then(Value::as_str)
2738 .map(|value| format!("brave:{value}"))
2739 .unwrap_or_else(|| "brave".to_string());
2740 Some(SearchResultEntry {
2741 title,
2742 url,
2743 snippet,
2744 source,
2745 })
2746 })
2747 .take(limit)
2748 .collect()
2749}
2750
2751fn stable_hash(input: &str) -> String {
2752 let mut hasher = DefaultHasher::new();
2753 input.hash(&mut hasher);
2754 format!("{:016x}", hasher.finish())
2755}
2756
2757fn extract_websearch_query(args: &Value) -> Option<String> {
2758 const QUERY_KEYS: [&str; 5] = ["query", "q", "search_query", "searchQuery", "keywords"];
2760 for key in QUERY_KEYS {
2761 if let Some(query) = args.get(key).and_then(|v| v.as_str()) {
2762 if let Some(cleaned) = sanitize_websearch_query_candidate(query) {
2763 return Some(cleaned);
2764 }
2765 }
2766 }
2767
2768 for container in ["arguments", "args", "input", "params"] {
2770 if let Some(obj) = args.get(container) {
2771 for key in QUERY_KEYS {
2772 if let Some(query) = obj.get(key).and_then(|v| v.as_str()) {
2773 if let Some(cleaned) = sanitize_websearch_query_candidate(query) {
2774 return Some(cleaned);
2775 }
2776 }
2777 }
2778 }
2779 }
2780
2781 args.as_str().and_then(sanitize_websearch_query_candidate)
2783}
2784
2785fn sanitize_websearch_query_candidate(raw: &str) -> Option<String> {
2786 let trimmed = raw.trim();
2787 if trimmed.is_empty() {
2788 return None;
2789 }
2790
2791 let lower = trimmed.to_ascii_lowercase();
2792 if let Some(start) = lower.find("<arg_value>") {
2793 let value_start = start + "<arg_value>".len();
2794 let tail = &trimmed[value_start..];
2795 let value = if let Some(end) = tail.to_ascii_lowercase().find("</arg_value>") {
2796 &tail[..end]
2797 } else {
2798 tail
2799 };
2800 let cleaned = value.trim();
2801 if !cleaned.is_empty() {
2802 return Some(cleaned.to_string());
2803 }
2804 }
2805
2806 let without_wrappers = trimmed
2807 .replace("<arg_key>", " ")
2808 .replace("</arg_key>", " ")
2809 .replace("<arg_value>", " ")
2810 .replace("</arg_value>", " ");
2811 let collapsed = without_wrappers
2812 .split_whitespace()
2813 .collect::<Vec<_>>()
2814 .join(" ");
2815 if collapsed.is_empty() {
2816 return None;
2817 }
2818
2819 let collapsed_lower = collapsed.to_ascii_lowercase();
2820 if let Some(rest) = collapsed_lower.strip_prefix("websearch query ") {
2821 let offset = collapsed.len() - rest.len();
2822 let q = collapsed[offset..].trim();
2823 if !q.is_empty() {
2824 return Some(q.to_string());
2825 }
2826 }
2827 if let Some(rest) = collapsed_lower.strip_prefix("query ") {
2828 let offset = collapsed.len() - rest.len();
2829 let q = collapsed[offset..].trim();
2830 if !q.is_empty() {
2831 return Some(q.to_string());
2832 }
2833 }
2834
2835 Some(collapsed)
2836}
2837
2838fn extract_websearch_limit(args: &Value) -> Option<u64> {
2839 let mut read_limit = |value: &Value| value.as_u64().map(|v| v.clamp(1, 10));
2840
2841 if let Some(limit) = args
2842 .get("limit")
2843 .and_then(&mut read_limit)
2844 .or_else(|| args.get("numResults").and_then(&mut read_limit))
2845 .or_else(|| args.get("num_results").and_then(&mut read_limit))
2846 {
2847 return Some(limit);
2848 }
2849
2850 for container in ["arguments", "args", "input", "params"] {
2851 if let Some(obj) = args.get(container) {
2852 if let Some(limit) = obj
2853 .get("limit")
2854 .and_then(&mut read_limit)
2855 .or_else(|| obj.get("numResults").and_then(&mut read_limit))
2856 .or_else(|| obj.get("num_results").and_then(&mut read_limit))
2857 {
2858 return Some(limit);
2859 }
2860 }
2861 }
2862 None
2863}
2864
2865struct CodeSearchTool;
2866#[async_trait]
2867impl Tool for CodeSearchTool {
2868 fn schema(&self) -> ToolSchema {
2869 tool_schema_with_capabilities(
2870 "codesearch",
2871 "Search code in workspace files",
2872 json!({"type":"object","properties":{"query":{"type":"string"},"path":{"type":"string"},"limit":{"type":"integer"}}}),
2873 workspace_search_capabilities(),
2874 )
2875 }
2876 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
2877 let query = args["query"].as_str().unwrap_or("").trim();
2878 if query.is_empty() {
2879 return Ok(ToolResult {
2880 output: "missing query".to_string(),
2881 metadata: json!({"count": 0}),
2882 });
2883 }
2884 let root = args["path"].as_str().unwrap_or(".");
2885 let Some(root_path) = resolve_walk_root(root, &args) else {
2886 return Ok(sandbox_path_denied_result(root, &args));
2887 };
2888 let limit = args["limit"]
2889 .as_u64()
2890 .map(|v| v.clamp(1, 200) as usize)
2891 .unwrap_or(50);
2892 let mut hits = Vec::new();
2893 let lower = query.to_lowercase();
2894 for entry in WalkBuilder::new(&root_path).build().flatten() {
2895 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
2896 continue;
2897 }
2898 let path = entry.path();
2899 let ext = path.extension().and_then(|v| v.to_str()).unwrap_or("");
2900 if !matches!(
2901 ext,
2902 "rs" | "ts" | "tsx" | "js" | "jsx" | "py" | "md" | "toml" | "json"
2903 ) {
2904 continue;
2905 }
2906 if let Ok(content) = fs::read_to_string(path).await {
2907 for (idx, line) in content.lines().enumerate() {
2908 if line.to_lowercase().contains(&lower) {
2909 hits.push(format!("{}:{}:{}", path.display(), idx + 1, line.trim()));
2910 if hits.len() >= limit {
2911 break;
2912 }
2913 }
2914 }
2915 }
2916 if hits.len() >= limit {
2917 break;
2918 }
2919 }
2920 Ok(ToolResult {
2921 output: hits.join("\n"),
2922 metadata: json!({"count": hits.len(), "query": query, "path": root_path.to_string_lossy()}),
2923 })
2924 }
2925}
2926
2927struct TodoWriteTool;
2928#[async_trait]
2929impl Tool for TodoWriteTool {
2930 fn schema(&self) -> ToolSchema {
2931 tool_schema(
2932 "todo_write",
2933 "Update todo list",
2934 json!({
2935 "type":"object",
2936 "properties":{
2937 "todos":{
2938 "type":"array",
2939 "items":{
2940 "type":"object",
2941 "properties":{
2942 "id":{"type":"string"},
2943 "content":{"type":"string"},
2944 "text":{"type":"string"},
2945 "status":{"type":"string"}
2946 }
2947 }
2948 }
2949 }
2950 }),
2951 )
2952 }
2953 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
2954 let todos = normalize_todos(args["todos"].as_array().cloned().unwrap_or_default());
2955 Ok(ToolResult {
2956 output: format!("todo list updated: {} items", todos.len()),
2957 metadata: json!({"todos": todos}),
2958 })
2959 }
2960}
2961
2962struct TaskTool;
2963#[async_trait]
2964impl Tool for TaskTool {
2965 fn schema(&self) -> ToolSchema {
2966 tool_schema(
2967 "task",
2968 "Create a subtask summary or spawn a teammate when team_name is provided.",
2969 task_schema(),
2970 )
2971 }
2972 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
2973 let input = serde_json::from_value::<TaskInput>(args.clone())
2974 .map_err(|err| anyhow!("invalid Task args: {}", err))?;
2975 let description = input.description;
2976 if let Some(team_name_raw) = input.team_name {
2977 let team_name = sanitize_team_name(&team_name_raw)?;
2978 let paths = resolve_agent_team_paths(&args)?;
2979 fs::create_dir_all(paths.team_dir(&team_name)).await?;
2980 fs::create_dir_all(paths.tasks_dir(&team_name)).await?;
2981 fs::create_dir_all(paths.mailboxes_dir(&team_name)).await?;
2982 fs::create_dir_all(paths.requests_dir(&team_name)).await?;
2983 upsert_team_index(&paths, &team_name).await?;
2984
2985 let member_name = if let Some(requested_name) = input.name {
2986 sanitize_member_name(&requested_name)?
2987 } else {
2988 next_default_member_name(&paths, &team_name).await?
2989 };
2990 let inserted = upsert_team_member(
2991 &paths,
2992 &team_name,
2993 &member_name,
2994 Some(input.subagent_type.clone()),
2995 input.model.clone(),
2996 )
2997 .await?;
2998 append_mailbox_message(
2999 &paths,
3000 &team_name,
3001 &member_name,
3002 json!({
3003 "id": format!("msg_{}", uuid_like(now_ms_u64())),
3004 "type": "task_prompt",
3005 "from": args.get("sender").and_then(|v| v.as_str()).unwrap_or("team-lead"),
3006 "to": member_name,
3007 "content": input.prompt,
3008 "summary": description,
3009 "timestampMs": now_ms_u64(),
3010 "read": false
3011 }),
3012 )
3013 .await?;
3014 let mut events = Vec::new();
3015 if inserted {
3016 events.push(json!({
3017 "type": "agent_team.member.spawned",
3018 "properties": {
3019 "teamName": team_name,
3020 "memberName": member_name,
3021 "agentType": input.subagent_type,
3022 "model": input.model,
3023 }
3024 }));
3025 }
3026 events.push(json!({
3027 "type": "agent_team.mailbox.enqueued",
3028 "properties": {
3029 "teamName": team_name,
3030 "recipient": member_name,
3031 "messageType": "task_prompt",
3032 }
3033 }));
3034 return Ok(ToolResult {
3035 output: format!("Teammate task queued for {}", member_name),
3036 metadata: json!({
3037 "ok": true,
3038 "team_name": team_name,
3039 "teammate_name": member_name,
3040 "events": events
3041 }),
3042 });
3043 }
3044 Ok(ToolResult {
3045 output: format!("Subtask planned: {description}"),
3046 metadata: json!({"description": description, "prompt": input.prompt}),
3047 })
3048 }
3049}
3050
3051struct QuestionTool;
3052#[async_trait]
3053impl Tool for QuestionTool {
3054 fn schema(&self) -> ToolSchema {
3055 tool_schema(
3056 "question",
3057 "Emit a question request for the user",
3058 json!({
3059 "type":"object",
3060 "properties":{
3061 "questions":{
3062 "type":"array",
3063 "items":{
3064 "type":"object",
3065 "properties":{
3066 "question":{"type":"string"},
3067 "choices":{"type":"array","items":{"type":"string"}}
3068 }
3069 }
3070 }
3071 }
3072 }),
3073 )
3074 }
3075 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
3076 let questions = normalize_question_payload(&args);
3077 if questions.is_empty() {
3078 return Err(anyhow!(
3079 "QUESTION_INVALID_ARGS: expected non-empty `questions` with at least one non-empty `question` string"
3080 ));
3081 }
3082 Ok(ToolResult {
3083 output: "Question requested. Use /question endpoints to respond.".to_string(),
3084 metadata: json!({"questions": questions}),
3085 })
3086 }
3087}
3088
3089fn normalize_question_payload(args: &Value) -> Vec<Value> {
3090 let parsed_args;
3091 let args = if let Some(raw) = args.as_str() {
3092 if let Ok(decoded) = serde_json::from_str::<Value>(raw) {
3093 parsed_args = decoded;
3094 &parsed_args
3095 } else {
3096 args
3097 }
3098 } else {
3099 args
3100 };
3101
3102 let Some(obj) = args.as_object() else {
3103 return Vec::new();
3104 };
3105
3106 if let Some(items) = obj.get("questions").and_then(|v| v.as_array()) {
3107 let normalized = items
3108 .iter()
3109 .filter_map(normalize_question_entry)
3110 .collect::<Vec<_>>();
3111 if !normalized.is_empty() {
3112 return normalized;
3113 }
3114 }
3115
3116 let question = obj
3117 .get("question")
3118 .or_else(|| obj.get("prompt"))
3119 .or_else(|| obj.get("query"))
3120 .or_else(|| obj.get("text"))
3121 .and_then(|v| v.as_str())
3122 .map(str::trim)
3123 .filter(|s| !s.is_empty());
3124 let Some(question) = question else {
3125 return Vec::new();
3126 };
3127 let options = obj
3128 .get("options")
3129 .or_else(|| obj.get("choices"))
3130 .and_then(|v| v.as_array())
3131 .map(|arr| {
3132 arr.iter()
3133 .filter_map(normalize_question_choice)
3134 .collect::<Vec<_>>()
3135 })
3136 .unwrap_or_default();
3137 let multiple = obj
3138 .get("multiple")
3139 .or_else(|| obj.get("multi_select"))
3140 .or_else(|| obj.get("multiSelect"))
3141 .and_then(|v| v.as_bool())
3142 .unwrap_or(false);
3143 let custom = obj
3144 .get("custom")
3145 .and_then(|v| v.as_bool())
3146 .unwrap_or(options.is_empty());
3147 vec![json!({
3148 "header": obj.get("header").and_then(|v| v.as_str()).unwrap_or("Question"),
3149 "question": question,
3150 "options": options,
3151 "multiple": multiple,
3152 "custom": custom
3153 })]
3154}
3155
3156fn normalize_question_entry(entry: &Value) -> Option<Value> {
3157 if let Some(raw) = entry.as_str() {
3158 let question = raw.trim();
3159 if question.is_empty() {
3160 return None;
3161 }
3162 return Some(json!({
3163 "header": "Question",
3164 "question": question,
3165 "options": [],
3166 "multiple": false,
3167 "custom": true
3168 }));
3169 }
3170 let obj = entry.as_object()?;
3171 let question = obj
3172 .get("question")
3173 .or_else(|| obj.get("prompt"))
3174 .or_else(|| obj.get("query"))
3175 .or_else(|| obj.get("text"))
3176 .and_then(|v| v.as_str())
3177 .map(str::trim)
3178 .filter(|s| !s.is_empty())?;
3179 let options = obj
3180 .get("options")
3181 .or_else(|| obj.get("choices"))
3182 .and_then(|v| v.as_array())
3183 .map(|arr| {
3184 arr.iter()
3185 .filter_map(normalize_question_choice)
3186 .collect::<Vec<_>>()
3187 })
3188 .unwrap_or_default();
3189 let multiple = obj
3190 .get("multiple")
3191 .or_else(|| obj.get("multi_select"))
3192 .or_else(|| obj.get("multiSelect"))
3193 .and_then(|v| v.as_bool())
3194 .unwrap_or(false);
3195 let custom = obj
3196 .get("custom")
3197 .and_then(|v| v.as_bool())
3198 .unwrap_or(options.is_empty());
3199 Some(json!({
3200 "header": obj.get("header").and_then(|v| v.as_str()).unwrap_or("Question"),
3201 "question": question,
3202 "options": options,
3203 "multiple": multiple,
3204 "custom": custom
3205 }))
3206}
3207
3208fn normalize_question_choice(choice: &Value) -> Option<Value> {
3209 if let Some(label) = choice.as_str().map(str::trim).filter(|s| !s.is_empty()) {
3210 return Some(json!({
3211 "label": label,
3212 "description": ""
3213 }));
3214 }
3215 let obj = choice.as_object()?;
3216 let label = obj
3217 .get("label")
3218 .or_else(|| obj.get("title"))
3219 .or_else(|| obj.get("name"))
3220 .or_else(|| obj.get("value"))
3221 .or_else(|| obj.get("text"))
3222 .and_then(|v| {
3223 if let Some(s) = v.as_str() {
3224 Some(s.trim().to_string())
3225 } else {
3226 v.as_i64()
3227 .map(|n| n.to_string())
3228 .or_else(|| v.as_u64().map(|n| n.to_string()))
3229 }
3230 })
3231 .filter(|s| !s.is_empty())?;
3232 let description = obj
3233 .get("description")
3234 .or_else(|| obj.get("hint"))
3235 .or_else(|| obj.get("subtitle"))
3236 .and_then(|v| v.as_str())
3237 .unwrap_or("")
3238 .to_string();
3239 Some(json!({
3240 "label": label,
3241 "description": description
3242 }))
3243}
3244
3245struct SpawnAgentTool;
3246#[async_trait]
3247impl Tool for SpawnAgentTool {
3248 fn schema(&self) -> ToolSchema {
3249 tool_schema(
3250 "spawn_agent",
3251 "Spawn an agent-team instance through server policy enforcement.",
3252 json!({
3253 "type":"object",
3254 "properties":{
3255 "missionID":{"type":"string"},
3256 "parentInstanceID":{"type":"string"},
3257 "templateID":{"type":"string"},
3258 "role":{"type":"string","enum":["orchestrator","delegator","worker","watcher","reviewer","tester","committer"]},
3259 "source":{"type":"string","enum":["tool_call"]},
3260 "justification":{"type":"string"},
3261 "budgetOverride":{"type":"object"}
3262 },
3263 "required":["role","justification"]
3264 }),
3265 )
3266 }
3267
3268 async fn execute(&self, _args: Value) -> anyhow::Result<ToolResult> {
3269 Ok(ToolResult {
3270 output: "spawn_agent must be executed through the engine runtime.".to_string(),
3271 metadata: json!({
3272 "ok": false,
3273 "code": "SPAWN_HOOK_UNAVAILABLE"
3274 }),
3275 })
3276 }
3277}
3278
3279struct TeamCreateTool;
3280#[async_trait]
3281impl Tool for TeamCreateTool {
3282 fn schema(&self) -> ToolSchema {
3283 tool_schema(
3284 "TeamCreate",
3285 "Create a coordinated team and shared task context.",
3286 team_create_schema(),
3287 )
3288 }
3289
3290 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
3291 let input = serde_json::from_value::<TeamCreateInput>(args.clone())
3292 .map_err(|err| anyhow!("invalid TeamCreate args: {}", err))?;
3293 let now_ms = now_ms_u64();
3294 let paths = resolve_agent_team_paths(&args)?;
3295 let team_name = sanitize_team_name(&input.team_name)?;
3296 let team_dir = paths.team_dir(&team_name);
3297 fs::create_dir_all(paths.tasks_dir(&team_name)).await?;
3298 fs::create_dir_all(paths.mailboxes_dir(&team_name)).await?;
3299 fs::create_dir_all(paths.requests_dir(&team_name)).await?;
3300
3301 let config = json!({
3302 "teamName": team_name,
3303 "description": input.description,
3304 "agentType": input.agent_type,
3305 "createdAtMs": now_ms
3306 });
3307 write_json_file(paths.config_file(&team_name), &config).await?;
3308
3309 let lead_name = args
3310 .get("lead_name")
3311 .and_then(|v| v.as_str())
3312 .filter(|s| !s.trim().is_empty())
3313 .unwrap_or("A1");
3314 let members = json!([{
3315 "name": lead_name,
3316 "agentType": input.agent_type.clone().unwrap_or_else(|| "lead".to_string()),
3317 "createdAtMs": now_ms
3318 }]);
3319 write_json_file(paths.members_file(&team_name), &members).await?;
3320
3321 upsert_team_index(&paths, &team_name).await?;
3322 if let Some(session_id) = args.get("__session_id").and_then(|v| v.as_str()) {
3323 write_team_session_context(&paths, session_id, &team_name).await?;
3324 }
3325
3326 Ok(ToolResult {
3327 output: format!("Team created: {}", team_name),
3328 metadata: json!({
3329 "ok": true,
3330 "team_name": team_name,
3331 "path": team_dir.to_string_lossy(),
3332 "events": [{
3333 "type": "agent_team.team.created",
3334 "properties": {
3335 "teamName": team_name,
3336 "path": team_dir.to_string_lossy(),
3337 }
3338 }]
3339 }),
3340 })
3341 }
3342}
3343
3344struct TaskCreateCompatTool;
3345#[async_trait]
3346impl Tool for TaskCreateCompatTool {
3347 fn schema(&self) -> ToolSchema {
3348 tool_schema(
3349 "TaskCreate",
3350 "Create a task in the shared team task list.",
3351 task_create_schema(),
3352 )
3353 }
3354
3355 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
3356 let input = serde_json::from_value::<TaskCreateInput>(args.clone())
3357 .map_err(|err| anyhow!("invalid TaskCreate args: {}", err))?;
3358 let paths = resolve_agent_team_paths(&args)?;
3359 let team_name = resolve_team_name(&paths, &args).await?;
3360 let tasks_dir = paths.tasks_dir(&team_name);
3361 fs::create_dir_all(&tasks_dir).await?;
3362 let next_id = next_task_id(&tasks_dir).await?;
3363 let now_ms = now_ms_u64();
3364 let task = json!({
3365 "id": next_id.to_string(),
3366 "subject": input.subject,
3367 "description": input.description,
3368 "activeForm": input.active_form,
3369 "status": "pending",
3370 "owner": Value::Null,
3371 "blocks": [],
3372 "blockedBy": [],
3373 "metadata": input.metadata.unwrap_or_else(|| json!({})),
3374 "createdAtMs": now_ms,
3375 "updatedAtMs": now_ms
3376 });
3377 write_json_file(paths.task_file(&team_name, &next_id.to_string()), &task).await?;
3378 Ok(ToolResult {
3379 output: format!("Task created: {}", next_id),
3380 metadata: json!({
3381 "ok": true,
3382 "team_name": team_name,
3383 "task": task,
3384 "events": [{
3385 "type": "agent_team.task.created",
3386 "properties": {
3387 "teamName": team_name,
3388 "taskId": next_id.to_string(),
3389 }
3390 }]
3391 }),
3392 })
3393 }
3394}
3395
3396struct TaskUpdateCompatTool;
3397#[async_trait]
3398impl Tool for TaskUpdateCompatTool {
3399 fn schema(&self) -> ToolSchema {
3400 tool_schema(
3401 "TaskUpdate",
3402 "Update ownership/state/dependencies of a shared task.",
3403 task_update_schema(),
3404 )
3405 }
3406
3407 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
3408 let input = serde_json::from_value::<TaskUpdateInput>(args.clone())
3409 .map_err(|err| anyhow!("invalid TaskUpdate args: {}", err))?;
3410 let paths = resolve_agent_team_paths(&args)?;
3411 let team_name = resolve_team_name(&paths, &args).await?;
3412 let task_path = paths.task_file(&team_name, &input.task_id);
3413 if !task_path.exists() {
3414 return Ok(ToolResult {
3415 output: format!("Task not found: {}", input.task_id),
3416 metadata: json!({"ok": false, "code": "TASK_NOT_FOUND"}),
3417 });
3418 }
3419 let raw = fs::read_to_string(&task_path).await?;
3420 let mut task = serde_json::from_str::<Value>(&raw)
3421 .map_err(|err| anyhow!("failed parsing task {}: {}", input.task_id, err))?;
3422 let Some(obj) = task.as_object_mut() else {
3423 return Err(anyhow!("task payload is not an object"));
3424 };
3425
3426 if let Some(subject) = input.subject {
3427 obj.insert("subject".to_string(), Value::String(subject));
3428 }
3429 if let Some(description) = input.description {
3430 obj.insert("description".to_string(), Value::String(description));
3431 }
3432 if let Some(active) = input.active_form {
3433 obj.insert("activeForm".to_string(), Value::String(active));
3434 }
3435 if let Some(status) = input.status {
3436 if status == "deleted" {
3437 let _ = fs::remove_file(&task_path).await;
3438 return Ok(ToolResult {
3439 output: format!("Task deleted: {}", input.task_id),
3440 metadata: json!({
3441 "ok": true,
3442 "deleted": true,
3443 "taskId": input.task_id,
3444 "events": [{
3445 "type": "agent_team.task.deleted",
3446 "properties": {
3447 "teamName": team_name,
3448 "taskId": input.task_id
3449 }
3450 }]
3451 }),
3452 });
3453 }
3454 obj.insert("status".to_string(), Value::String(status));
3455 }
3456 if let Some(owner) = input.owner {
3457 obj.insert("owner".to_string(), Value::String(owner));
3458 }
3459 if let Some(add_blocks) = input.add_blocks {
3460 let current = obj
3461 .get("blocks")
3462 .and_then(|v| v.as_array())
3463 .cloned()
3464 .unwrap_or_default();
3465 obj.insert(
3466 "blocks".to_string(),
3467 Value::Array(merge_unique_strings(current, add_blocks)),
3468 );
3469 }
3470 if let Some(add_blocked_by) = input.add_blocked_by {
3471 let current = obj
3472 .get("blockedBy")
3473 .and_then(|v| v.as_array())
3474 .cloned()
3475 .unwrap_or_default();
3476 obj.insert(
3477 "blockedBy".to_string(),
3478 Value::Array(merge_unique_strings(current, add_blocked_by)),
3479 );
3480 }
3481 if let Some(metadata) = input.metadata {
3482 if let Some(current) = obj.get_mut("metadata").and_then(|v| v.as_object_mut()) {
3483 if let Some(incoming) = metadata.as_object() {
3484 for (k, v) in incoming {
3485 if v.is_null() {
3486 current.remove(k);
3487 } else {
3488 current.insert(k.clone(), v.clone());
3489 }
3490 }
3491 }
3492 } else {
3493 obj.insert("metadata".to_string(), metadata);
3494 }
3495 }
3496 obj.insert("updatedAtMs".to_string(), json!(now_ms_u64()));
3497 write_json_file(task_path, &task).await?;
3498 Ok(ToolResult {
3499 output: format!("Task updated: {}", input.task_id),
3500 metadata: json!({
3501 "ok": true,
3502 "team_name": team_name,
3503 "task": task,
3504 "events": [{
3505 "type": "agent_team.task.updated",
3506 "properties": {
3507 "teamName": team_name,
3508 "taskId": input.task_id
3509 }
3510 }]
3511 }),
3512 })
3513 }
3514}
3515
3516struct TaskListCompatTool;
3517#[async_trait]
3518impl Tool for TaskListCompatTool {
3519 fn schema(&self) -> ToolSchema {
3520 tool_schema(
3521 "TaskList",
3522 "List tasks from the shared task list.",
3523 task_list_schema(),
3524 )
3525 }
3526
3527 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
3528 let _ = serde_json::from_value::<TaskListInput>(args.clone())
3529 .map_err(|err| anyhow!("invalid TaskList args: {}", err))?;
3530 let paths = resolve_agent_team_paths(&args)?;
3531 let team_name = resolve_team_name(&paths, &args).await?;
3532 let tasks = read_tasks(&paths.tasks_dir(&team_name)).await?;
3533 let mut lines = Vec::new();
3534 for task in &tasks {
3535 let id = task
3536 .get("id")
3537 .and_then(|v| v.as_str())
3538 .unwrap_or("?")
3539 .to_string();
3540 let subject = task
3541 .get("subject")
3542 .and_then(|v| v.as_str())
3543 .unwrap_or("(untitled)")
3544 .to_string();
3545 let status = task
3546 .get("status")
3547 .and_then(|v| v.as_str())
3548 .unwrap_or("pending")
3549 .to_string();
3550 let owner = task
3551 .get("owner")
3552 .and_then(|v| v.as_str())
3553 .unwrap_or("")
3554 .to_string();
3555 lines.push(format!(
3556 "{} [{}] {}{}",
3557 id,
3558 status,
3559 subject,
3560 if owner.is_empty() {
3561 "".to_string()
3562 } else {
3563 format!(" (owner: {})", owner)
3564 }
3565 ));
3566 }
3567 Ok(ToolResult {
3568 output: if lines.is_empty() {
3569 "No tasks.".to_string()
3570 } else {
3571 lines.join("\n")
3572 },
3573 metadata: json!({
3574 "ok": true,
3575 "team_name": team_name,
3576 "tasks": tasks
3577 }),
3578 })
3579 }
3580}
3581
3582struct SendMessageCompatTool;
3583#[async_trait]
3584impl Tool for SendMessageCompatTool {
3585 fn schema(&self) -> ToolSchema {
3586 tool_schema(
3587 "SendMessage",
3588 "Send teammate messages and coordination protocol responses.",
3589 send_message_schema(),
3590 )
3591 }
3592
3593 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
3594 let input = serde_json::from_value::<SendMessageInput>(args.clone())
3595 .map_err(|err| anyhow!("invalid SendMessage args: {}", err))?;
3596 let paths = resolve_agent_team_paths(&args)?;
3597 let team_name = resolve_team_name(&paths, &args).await?;
3598 fs::create_dir_all(paths.mailboxes_dir(&team_name)).await?;
3599 let sender = args
3600 .get("sender")
3601 .and_then(|v| v.as_str())
3602 .filter(|s| !s.trim().is_empty())
3603 .unwrap_or("team-lead")
3604 .to_string();
3605 let now_ms = now_ms_u64();
3606
3607 match input.message_type {
3608 SendMessageType::Message | SendMessageType::ShutdownRequest => {
3609 let recipient = required_non_empty(input.recipient, "recipient")?;
3610 let content = required_non_empty(input.content, "content")?;
3611 append_mailbox_message(
3612 &paths,
3613 &team_name,
3614 &recipient,
3615 json!({
3616 "id": format!("msg_{}", uuid_like(now_ms)),
3617 "type": message_type_name(&input.message_type),
3618 "from": sender,
3619 "to": recipient,
3620 "content": content,
3621 "summary": input.summary,
3622 "timestampMs": now_ms,
3623 "read": false
3624 }),
3625 )
3626 .await?;
3627 Ok(ToolResult {
3628 output: "Message queued.".to_string(),
3629 metadata: json!({
3630 "ok": true,
3631 "team_name": team_name,
3632 "events": [{
3633 "type": "agent_team.mailbox.enqueued",
3634 "properties": {
3635 "teamName": team_name,
3636 "recipient": recipient,
3637 "messageType": message_type_name(&input.message_type),
3638 }
3639 }]
3640 }),
3641 })
3642 }
3643 SendMessageType::Broadcast => {
3644 let content = required_non_empty(input.content, "content")?;
3645 let members = read_team_member_names(&paths, &team_name).await?;
3646 for recipient in members {
3647 append_mailbox_message(
3648 &paths,
3649 &team_name,
3650 &recipient,
3651 json!({
3652 "id": format!("msg_{}_{}", uuid_like(now_ms), recipient),
3653 "type": "broadcast",
3654 "from": sender,
3655 "to": recipient,
3656 "content": content,
3657 "summary": input.summary,
3658 "timestampMs": now_ms,
3659 "read": false
3660 }),
3661 )
3662 .await?;
3663 }
3664 Ok(ToolResult {
3665 output: "Broadcast queued.".to_string(),
3666 metadata: json!({
3667 "ok": true,
3668 "team_name": team_name,
3669 "events": [{
3670 "type": "agent_team.mailbox.enqueued",
3671 "properties": {
3672 "teamName": team_name,
3673 "recipient": "*",
3674 "messageType": "broadcast",
3675 }
3676 }]
3677 }),
3678 })
3679 }
3680 SendMessageType::ShutdownResponse | SendMessageType::PlanApprovalResponse => {
3681 let request_id = required_non_empty(input.request_id, "request_id")?;
3682 let request = json!({
3683 "requestId": request_id,
3684 "type": message_type_name(&input.message_type),
3685 "from": sender,
3686 "recipient": input.recipient,
3687 "approve": input.approve,
3688 "content": input.content,
3689 "updatedAtMs": now_ms
3690 });
3691 write_json_file(paths.request_file(&team_name, &request_id), &request).await?;
3692 Ok(ToolResult {
3693 output: "Response recorded.".to_string(),
3694 metadata: json!({
3695 "ok": true,
3696 "team_name": team_name,
3697 "request": request,
3698 "events": [{
3699 "type": "agent_team.request.resolved",
3700 "properties": {
3701 "teamName": team_name,
3702 "requestId": request_id,
3703 "requestType": message_type_name(&input.message_type),
3704 "approve": input.approve
3705 }
3706 }]
3707 }),
3708 })
3709 }
3710 }
3711 }
3712}
3713
3714fn message_type_name(ty: &SendMessageType) -> &'static str {
3715 match ty {
3716 SendMessageType::Message => "message",
3717 SendMessageType::Broadcast => "broadcast",
3718 SendMessageType::ShutdownRequest => "shutdown_request",
3719 SendMessageType::ShutdownResponse => "shutdown_response",
3720 SendMessageType::PlanApprovalResponse => "plan_approval_response",
3721 }
3722}
3723
3724fn required_non_empty(value: Option<String>, field: &str) -> anyhow::Result<String> {
3725 let Some(v) = value
3726 .map(|s| s.trim().to_string())
3727 .filter(|s| !s.is_empty())
3728 else {
3729 return Err(anyhow!("{} is required", field));
3730 };
3731 Ok(v)
3732}
3733
3734fn resolve_agent_team_paths(args: &Value) -> anyhow::Result<AgentTeamPaths> {
3735 let workspace_root = args
3736 .get("__workspace_root")
3737 .and_then(|v| v.as_str())
3738 .map(PathBuf::from)
3739 .or_else(|| std::env::current_dir().ok())
3740 .ok_or_else(|| anyhow!("workspace root unavailable"))?;
3741 Ok(AgentTeamPaths::new(workspace_root.join(".tandem")))
3742}
3743
3744async fn resolve_team_name(paths: &AgentTeamPaths, args: &Value) -> anyhow::Result<String> {
3745 if let Some(name) = args
3746 .get("team_name")
3747 .and_then(|v| v.as_str())
3748 .map(str::trim)
3749 .filter(|s| !s.is_empty())
3750 {
3751 return sanitize_team_name(name);
3752 }
3753 if let Some(session_id) = args.get("__session_id").and_then(|v| v.as_str()) {
3754 let context_path = paths
3755 .root()
3756 .join("session-context")
3757 .join(format!("{}.json", session_id));
3758 if context_path.exists() {
3759 let raw = fs::read_to_string(context_path).await?;
3760 let parsed = serde_json::from_str::<Value>(&raw)?;
3761 if let Some(name) = parsed
3762 .get("team_name")
3763 .and_then(|v| v.as_str())
3764 .map(str::trim)
3765 .filter(|s| !s.is_empty())
3766 {
3767 return sanitize_team_name(name);
3768 }
3769 }
3770 }
3771 Err(anyhow!(
3772 "team_name is required (no active team context for this session)"
3773 ))
3774}
3775
3776fn sanitize_team_name(input: &str) -> anyhow::Result<String> {
3777 let trimmed = input.trim();
3778 if trimmed.is_empty() {
3779 return Err(anyhow!("team_name cannot be empty"));
3780 }
3781 let sanitized = trimmed
3782 .chars()
3783 .map(|c| {
3784 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
3785 c
3786 } else {
3787 '-'
3788 }
3789 })
3790 .collect::<String>();
3791 Ok(sanitized)
3792}
3793
3794fn sanitize_member_name(input: &str) -> anyhow::Result<String> {
3795 let trimmed = input.trim();
3796 if trimmed.is_empty() {
3797 return Err(anyhow!("member name cannot be empty"));
3798 }
3799 if let Some(rest) = trimmed
3800 .strip_prefix('A')
3801 .or_else(|| trimmed.strip_prefix('a'))
3802 {
3803 if let Ok(n) = rest.parse::<u32>() {
3804 if n > 0 {
3805 return Ok(format!("A{}", n));
3806 }
3807 }
3808 }
3809 let sanitized = trimmed
3810 .chars()
3811 .map(|c| {
3812 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
3813 c
3814 } else {
3815 '-'
3816 }
3817 })
3818 .collect::<String>();
3819 if sanitized.is_empty() {
3820 return Err(anyhow!("member name cannot be empty"));
3821 }
3822 Ok(sanitized)
3823}
3824
3825async fn next_default_member_name(
3826 paths: &AgentTeamPaths,
3827 team_name: &str,
3828) -> anyhow::Result<String> {
3829 let names = read_team_member_names(paths, team_name).await?;
3830 let mut max_index = 1u32;
3831 for name in names {
3832 let trimmed = name.trim();
3833 let Some(rest) = trimmed
3834 .strip_prefix('A')
3835 .or_else(|| trimmed.strip_prefix('a'))
3836 else {
3837 continue;
3838 };
3839 let Ok(index) = rest.parse::<u32>() else {
3840 continue;
3841 };
3842 if index > max_index {
3843 max_index = index;
3844 }
3845 }
3846 Ok(format!("A{}", max_index.saturating_add(1)))
3847}
3848
3849async fn write_json_file(path: PathBuf, value: &Value) -> anyhow::Result<()> {
3850 if let Some(parent) = path.parent() {
3851 fs::create_dir_all(parent).await?;
3852 }
3853 fs::write(path, serde_json::to_vec_pretty(value)?).await?;
3854 Ok(())
3855}
3856
3857async fn upsert_team_index(paths: &AgentTeamPaths, team_name: &str) -> anyhow::Result<()> {
3858 let index_path = paths.index_file();
3859 let mut teams = if index_path.exists() {
3860 let raw = fs::read_to_string(&index_path).await?;
3861 serde_json::from_str::<Vec<String>>(&raw).unwrap_or_default()
3862 } else {
3863 Vec::new()
3864 };
3865 if !teams.iter().any(|team| team == team_name) {
3866 teams.push(team_name.to_string());
3867 teams.sort();
3868 }
3869 write_json_file(index_path, &json!(teams)).await
3870}
3871
3872async fn write_team_session_context(
3873 paths: &AgentTeamPaths,
3874 session_id: &str,
3875 team_name: &str,
3876) -> anyhow::Result<()> {
3877 let context_path = paths
3878 .root()
3879 .join("session-context")
3880 .join(format!("{}.json", session_id));
3881 write_json_file(context_path, &json!({ "team_name": team_name })).await
3882}
3883
3884async fn next_task_id(tasks_dir: &Path) -> anyhow::Result<u64> {
3885 let mut max_id = 0u64;
3886 let mut entries = match fs::read_dir(tasks_dir).await {
3887 Ok(entries) => entries,
3888 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(1),
3889 Err(err) => return Err(err.into()),
3890 };
3891 while let Some(entry) = entries.next_entry().await? {
3892 let path = entry.path();
3893 if !path.is_file() {
3894 continue;
3895 }
3896 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
3897 continue;
3898 };
3899 if let Ok(id) = stem.parse::<u64>() {
3900 max_id = max_id.max(id);
3901 }
3902 }
3903 Ok(max_id + 1)
3904}
3905
3906fn merge_unique_strings(current: Vec<Value>, incoming: Vec<String>) -> Vec<Value> {
3907 let mut seen = HashSet::new();
3908 let mut out = Vec::new();
3909 for value in current {
3910 if let Some(text) = value.as_str() {
3911 let text = text.to_string();
3912 if seen.insert(text.clone()) {
3913 out.push(Value::String(text));
3914 }
3915 }
3916 }
3917 for value in incoming {
3918 if seen.insert(value.clone()) {
3919 out.push(Value::String(value));
3920 }
3921 }
3922 out
3923}
3924
3925async fn read_tasks(tasks_dir: &Path) -> anyhow::Result<Vec<Value>> {
3926 let mut tasks = Vec::new();
3927 let mut entries = match fs::read_dir(tasks_dir).await {
3928 Ok(entries) => entries,
3929 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(tasks),
3930 Err(err) => return Err(err.into()),
3931 };
3932 while let Some(entry) = entries.next_entry().await? {
3933 let path = entry.path();
3934 if !path.is_file() {
3935 continue;
3936 }
3937 let raw = fs::read_to_string(path).await?;
3938 let task = serde_json::from_str::<Value>(&raw)?;
3939 tasks.push(task);
3940 }
3941 tasks.sort_by_key(|task| {
3942 task.get("id")
3943 .and_then(|v| v.as_str())
3944 .and_then(|s| s.parse::<u64>().ok())
3945 .unwrap_or(0)
3946 });
3947 Ok(tasks)
3948}
3949
3950async fn append_mailbox_message(
3951 paths: &AgentTeamPaths,
3952 team_name: &str,
3953 recipient: &str,
3954 message: Value,
3955) -> anyhow::Result<()> {
3956 let mailbox_path = paths.mailbox_file(team_name, recipient);
3957 if let Some(parent) = mailbox_path.parent() {
3958 fs::create_dir_all(parent).await?;
3959 }
3960 let line = format!("{}\n", serde_json::to_string(&message)?);
3961 if mailbox_path.exists() {
3962 use tokio::io::AsyncWriteExt;
3963 let mut file = tokio::fs::OpenOptions::new()
3964 .create(true)
3965 .append(true)
3966 .open(mailbox_path)
3967 .await?;
3968 file.write_all(line.as_bytes()).await?;
3969 file.flush().await?;
3970 } else {
3971 fs::write(mailbox_path, line).await?;
3972 }
3973 Ok(())
3974}
3975
3976async fn read_team_member_names(
3977 paths: &AgentTeamPaths,
3978 team_name: &str,
3979) -> anyhow::Result<Vec<String>> {
3980 let members_path = paths.members_file(team_name);
3981 if !members_path.exists() {
3982 return Ok(Vec::new());
3983 }
3984 let raw = fs::read_to_string(members_path).await?;
3985 let parsed = serde_json::from_str::<Value>(&raw)?;
3986 let Some(items) = parsed.as_array() else {
3987 return Ok(Vec::new());
3988 };
3989 let mut out = Vec::new();
3990 for item in items {
3991 if let Some(name) = item
3992 .get("name")
3993 .and_then(|v| v.as_str())
3994 .map(str::trim)
3995 .filter(|s| !s.is_empty())
3996 {
3997 out.push(name.to_string());
3998 }
3999 }
4000 Ok(out)
4001}
4002
4003async fn upsert_team_member(
4004 paths: &AgentTeamPaths,
4005 team_name: &str,
4006 member_name: &str,
4007 agent_type: Option<String>,
4008 model: Option<String>,
4009) -> anyhow::Result<bool> {
4010 let members_path = paths.members_file(team_name);
4011 let mut members = if members_path.exists() {
4012 let raw = fs::read_to_string(&members_path).await?;
4013 serde_json::from_str::<Value>(&raw)?
4014 .as_array()
4015 .cloned()
4016 .unwrap_or_default()
4017 } else {
4018 Vec::new()
4019 };
4020 let already_present = members.iter().any(|item| {
4021 item.get("name")
4022 .and_then(|v| v.as_str())
4023 .map(|s| s == member_name)
4024 .unwrap_or(false)
4025 });
4026 if already_present {
4027 return Ok(false);
4028 }
4029 members.push(json!({
4030 "name": member_name,
4031 "agentType": agent_type,
4032 "model": model,
4033 "createdAtMs": now_ms_u64()
4034 }));
4035 write_json_file(members_path, &Value::Array(members)).await?;
4036 Ok(true)
4037}
4038
4039fn now_ms_u64() -> u64 {
4040 std::time::SystemTime::now()
4041 .duration_since(std::time::UNIX_EPOCH)
4042 .map(|d| d.as_millis() as u64)
4043 .unwrap_or(0)
4044}
4045
4046fn uuid_like(seed: u64) -> String {
4047 format!("{:x}", seed)
4048}
4049
4050struct MemorySearchTool;
4051#[async_trait]
4052impl Tool for MemorySearchTool {
4053 fn schema(&self) -> ToolSchema {
4054 tool_schema(
4055 "memory_search",
4056 "Search tandem memory across session/project/global tiers. If scope fields are omitted, the tool defaults to the current session/project context and may include global memory when policy allows it.",
4057 json!({
4058 "type":"object",
4059 "properties":{
4060 "query":{"type":"string"},
4061 "session_id":{"type":"string"},
4062 "project_id":{"type":"string"},
4063 "tier":{"type":"string","enum":["session","project","global"]},
4064 "limit":{"type":"integer","minimum":1,"maximum":20},
4065 "allow_global":{"type":"boolean"}
4066 },
4067 "required":["query"]
4068 }),
4069 )
4070 }
4071
4072 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
4073 let query = args
4074 .get("query")
4075 .or_else(|| args.get("q"))
4076 .and_then(|v| v.as_str())
4077 .map(str::trim)
4078 .unwrap_or("");
4079 if query.is_empty() {
4080 return Ok(ToolResult {
4081 output: "memory_search requires a non-empty query".to_string(),
4082 metadata: json!({"ok": false, "reason": "missing_query"}),
4083 });
4084 }
4085
4086 let session_id = memory_session_id(&args);
4087 let project_id = memory_project_id(&args);
4088 let allow_global = global_memory_enabled(&args);
4089 if session_id.is_none() && project_id.is_none() && !allow_global {
4090 return Ok(ToolResult {
4091 output: "memory_search requires a current session/project context or global memory enabled by policy"
4092 .to_string(),
4093 metadata: json!({"ok": false, "reason": "missing_scope"}),
4094 });
4095 }
4096
4097 let tier = match args
4098 .get("tier")
4099 .and_then(|v| v.as_str())
4100 .map(|s| s.trim().to_ascii_lowercase())
4101 {
4102 Some(t) if t == "session" => Some(MemoryTier::Session),
4103 Some(t) if t == "project" => Some(MemoryTier::Project),
4104 Some(t) if t == "global" => Some(MemoryTier::Global),
4105 Some(_) => {
4106 return Ok(ToolResult {
4107 output: "memory_search tier must be one of: session, project, global"
4108 .to_string(),
4109 metadata: json!({"ok": false, "reason": "invalid_tier"}),
4110 });
4111 }
4112 None => None,
4113 };
4114 if matches!(tier, Some(MemoryTier::Session)) && session_id.is_none() {
4115 return Ok(ToolResult {
4116 output: "tier=session requires session_id".to_string(),
4117 metadata: json!({"ok": false, "reason": "missing_session_scope"}),
4118 });
4119 }
4120 if matches!(tier, Some(MemoryTier::Project)) && project_id.is_none() {
4121 return Ok(ToolResult {
4122 output: "tier=project requires project_id".to_string(),
4123 metadata: json!({"ok": false, "reason": "missing_project_scope"}),
4124 });
4125 }
4126 if matches!(tier, Some(MemoryTier::Global)) && !allow_global {
4127 return Ok(ToolResult {
4128 output: "tier=global requires allow_global=true".to_string(),
4129 metadata: json!({"ok": false, "reason": "global_scope_disabled"}),
4130 });
4131 }
4132
4133 let limit = args
4134 .get("limit")
4135 .and_then(|v| v.as_i64())
4136 .unwrap_or(5)
4137 .clamp(1, 20);
4138
4139 let db_path = resolve_memory_db_path(&args);
4140 let db_exists = db_path.exists();
4141 if !db_exists {
4142 return Ok(ToolResult {
4143 output: "memory database not found".to_string(),
4144 metadata: json!({
4145 "ok": false,
4146 "reason": "memory_db_missing",
4147 "db_path": db_path,
4148 }),
4149 });
4150 }
4151
4152 let manager = MemoryManager::new(&db_path).await?;
4153 let health = manager.embedding_health().await;
4154 if health.status != "ok" {
4155 return Ok(ToolResult {
4156 output: "memory embeddings unavailable; semantic search is disabled".to_string(),
4157 metadata: json!({
4158 "ok": false,
4159 "reason": "embeddings_unavailable",
4160 "embedding_status": health.status,
4161 "embedding_reason": health.reason,
4162 }),
4163 });
4164 }
4165
4166 let mut results: Vec<MemorySearchResult> = Vec::new();
4167 match tier {
4168 Some(MemoryTier::Session) => {
4169 results.extend(
4170 manager
4171 .search(
4172 query,
4173 Some(MemoryTier::Session),
4174 project_id.as_deref(),
4175 session_id.as_deref(),
4176 Some(limit),
4177 )
4178 .await?,
4179 );
4180 }
4181 Some(MemoryTier::Project) => {
4182 results.extend(
4183 manager
4184 .search(
4185 query,
4186 Some(MemoryTier::Project),
4187 project_id.as_deref(),
4188 session_id.as_deref(),
4189 Some(limit),
4190 )
4191 .await?,
4192 );
4193 }
4194 Some(MemoryTier::Global) => {
4195 results.extend(
4196 manager
4197 .search(query, Some(MemoryTier::Global), None, None, Some(limit))
4198 .await?,
4199 );
4200 }
4201 _ => {
4202 if session_id.is_some() {
4203 results.extend(
4204 manager
4205 .search(
4206 query,
4207 Some(MemoryTier::Session),
4208 project_id.as_deref(),
4209 session_id.as_deref(),
4210 Some(limit),
4211 )
4212 .await?,
4213 );
4214 }
4215 if project_id.is_some() {
4216 results.extend(
4217 manager
4218 .search(
4219 query,
4220 Some(MemoryTier::Project),
4221 project_id.as_deref(),
4222 session_id.as_deref(),
4223 Some(limit),
4224 )
4225 .await?,
4226 );
4227 }
4228 if allow_global {
4229 results.extend(
4230 manager
4231 .search(query, Some(MemoryTier::Global), None, None, Some(limit))
4232 .await?,
4233 );
4234 }
4235 }
4236 }
4237
4238 let mut dedup: HashMap<String, MemorySearchResult> = HashMap::new();
4239 for result in results {
4240 match dedup.get(&result.chunk.id) {
4241 Some(existing) if existing.similarity >= result.similarity => {}
4242 _ => {
4243 dedup.insert(result.chunk.id.clone(), result);
4244 }
4245 }
4246 }
4247 let mut merged = dedup.into_values().collect::<Vec<_>>();
4248 merged.sort_by(|a, b| b.similarity.total_cmp(&a.similarity));
4249 merged.truncate(limit as usize);
4250
4251 let output_rows = merged
4252 .iter()
4253 .map(|item| {
4254 json!({
4255 "chunk_id": item.chunk.id,
4256 "tier": item.chunk.tier.to_string(),
4257 "session_id": item.chunk.session_id,
4258 "project_id": item.chunk.project_id,
4259 "source": item.chunk.source,
4260 "similarity": item.similarity,
4261 "content": item.chunk.content,
4262 "created_at": item.chunk.created_at,
4263 })
4264 })
4265 .collect::<Vec<_>>();
4266
4267 Ok(ToolResult {
4268 output: serde_json::to_string_pretty(&output_rows).unwrap_or_default(),
4269 metadata: json!({
4270 "ok": true,
4271 "count": output_rows.len(),
4272 "limit": limit,
4273 "query": query,
4274 "session_id": session_id,
4275 "project_id": project_id,
4276 "allow_global": allow_global,
4277 "embedding_status": health.status,
4278 "embedding_reason": health.reason,
4279 "strict_scope": !allow_global,
4280 }),
4281 })
4282 }
4283}
4284
4285struct MemoryStoreTool;
4286#[async_trait]
4287impl Tool for MemoryStoreTool {
4288 fn schema(&self) -> ToolSchema {
4289 tool_schema(
4290 "memory_store",
4291 "Store memory chunks in session/project/global tiers. If scope is omitted, the tool defaults to the current project, then session, and only uses global memory when policy allows it.",
4292 json!({
4293 "type":"object",
4294 "properties":{
4295 "content":{"type":"string"},
4296 "tier":{"type":"string","enum":["session","project","global"]},
4297 "session_id":{"type":"string"},
4298 "project_id":{"type":"string"},
4299 "source":{"type":"string"},
4300 "metadata":{"type":"object"},
4301 "allow_global":{"type":"boolean"}
4302 },
4303 "required":["content"]
4304 }),
4305 )
4306 }
4307
4308 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
4309 let content = args
4310 .get("content")
4311 .and_then(|v| v.as_str())
4312 .map(str::trim)
4313 .unwrap_or("");
4314 if content.is_empty() {
4315 return Ok(ToolResult {
4316 output: "memory_store requires non-empty content".to_string(),
4317 metadata: json!({"ok": false, "reason": "missing_content"}),
4318 });
4319 }
4320
4321 let session_id = memory_session_id(&args);
4322 let project_id = memory_project_id(&args);
4323 let allow_global = global_memory_enabled(&args);
4324
4325 let tier = match args
4326 .get("tier")
4327 .and_then(|v| v.as_str())
4328 .map(|s| s.trim().to_ascii_lowercase())
4329 {
4330 Some(t) if t == "session" => MemoryTier::Session,
4331 Some(t) if t == "project" => MemoryTier::Project,
4332 Some(t) if t == "global" => MemoryTier::Global,
4333 Some(_) => {
4334 return Ok(ToolResult {
4335 output: "memory_store tier must be one of: session, project, global"
4336 .to_string(),
4337 metadata: json!({"ok": false, "reason": "invalid_tier"}),
4338 });
4339 }
4340 None => {
4341 if project_id.is_some() {
4342 MemoryTier::Project
4343 } else if session_id.is_some() {
4344 MemoryTier::Session
4345 } else if allow_global {
4346 MemoryTier::Global
4347 } else {
4348 return Ok(ToolResult {
4349 output: "memory_store requires a current session/project context or global memory enabled by policy"
4350 .to_string(),
4351 metadata: json!({"ok": false, "reason": "missing_scope"}),
4352 });
4353 }
4354 }
4355 };
4356
4357 if matches!(tier, MemoryTier::Session) && session_id.is_none() {
4358 return Ok(ToolResult {
4359 output: "tier=session requires session_id".to_string(),
4360 metadata: json!({"ok": false, "reason": "missing_session_scope"}),
4361 });
4362 }
4363 if matches!(tier, MemoryTier::Project) && project_id.is_none() {
4364 return Ok(ToolResult {
4365 output: "tier=project requires project_id".to_string(),
4366 metadata: json!({"ok": false, "reason": "missing_project_scope"}),
4367 });
4368 }
4369 if matches!(tier, MemoryTier::Global) && !allow_global {
4370 return Ok(ToolResult {
4371 output: "tier=global requires allow_global=true".to_string(),
4372 metadata: json!({"ok": false, "reason": "global_scope_disabled"}),
4373 });
4374 }
4375
4376 let db_path = resolve_memory_db_path(&args);
4377 let manager = MemoryManager::new(&db_path).await?;
4378 let health = manager.embedding_health().await;
4379 if health.status != "ok" {
4380 return Ok(ToolResult {
4381 output: "memory embeddings unavailable; semantic memory store is disabled"
4382 .to_string(),
4383 metadata: json!({
4384 "ok": false,
4385 "reason": "embeddings_unavailable",
4386 "embedding_status": health.status,
4387 "embedding_reason": health.reason,
4388 }),
4389 });
4390 }
4391
4392 let source = args
4393 .get("source")
4394 .and_then(|v| v.as_str())
4395 .map(str::trim)
4396 .filter(|s| !s.is_empty())
4397 .unwrap_or("agent_note")
4398 .to_string();
4399 let metadata = args.get("metadata").cloned();
4400
4401 let request = tandem_memory::types::StoreMessageRequest {
4402 content: content.to_string(),
4403 tier,
4404 session_id: session_id.clone(),
4405 project_id: project_id.clone(),
4406 source,
4407 source_path: None,
4408 source_mtime: None,
4409 source_size: None,
4410 source_hash: None,
4411 metadata,
4412 };
4413 let chunk_ids = manager.store_message(request).await?;
4414
4415 Ok(ToolResult {
4416 output: format!("stored {} chunk(s) in {} memory", chunk_ids.len(), tier),
4417 metadata: json!({
4418 "ok": true,
4419 "chunk_ids": chunk_ids,
4420 "count": chunk_ids.len(),
4421 "tier": tier.to_string(),
4422 "session_id": session_id,
4423 "project_id": project_id,
4424 "allow_global": allow_global,
4425 "embedding_status": health.status,
4426 "embedding_reason": health.reason,
4427 "db_path": db_path,
4428 }),
4429 })
4430 }
4431}
4432
4433struct MemoryListTool;
4434#[async_trait]
4435impl Tool for MemoryListTool {
4436 fn schema(&self) -> ToolSchema {
4437 tool_schema(
4438 "memory_list",
4439 "List stored memory chunks for auditing and knowledge-base browsing.",
4440 json!({
4441 "type":"object",
4442 "properties":{
4443 "tier":{"type":"string","enum":["session","project","global","all"]},
4444 "session_id":{"type":"string"},
4445 "project_id":{"type":"string"},
4446 "limit":{"type":"integer","minimum":1,"maximum":200},
4447 "allow_global":{"type":"boolean"}
4448 }
4449 }),
4450 )
4451 }
4452
4453 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
4454 let session_id = memory_session_id(&args);
4455 let project_id = memory_project_id(&args);
4456 let allow_global = global_memory_enabled(&args);
4457 let limit = args
4458 .get("limit")
4459 .and_then(|v| v.as_i64())
4460 .unwrap_or(50)
4461 .clamp(1, 200) as usize;
4462
4463 let tier = args
4464 .get("tier")
4465 .and_then(|v| v.as_str())
4466 .map(|s| s.trim().to_ascii_lowercase())
4467 .unwrap_or_else(|| "all".to_string());
4468 if tier == "global" && !allow_global {
4469 return Ok(ToolResult {
4470 output: "tier=global requires allow_global=true".to_string(),
4471 metadata: json!({"ok": false, "reason": "global_scope_disabled"}),
4472 });
4473 }
4474 if session_id.is_none() && project_id.is_none() && tier != "global" && !allow_global {
4475 return Ok(ToolResult {
4476 output: "memory_list requires a current session/project context or global memory enabled by policy".to_string(),
4477 metadata: json!({"ok": false, "reason": "missing_scope"}),
4478 });
4479 }
4480
4481 let db_path = resolve_memory_db_path(&args);
4482 let manager = MemoryManager::new(&db_path).await?;
4483
4484 let mut chunks: Vec<tandem_memory::types::MemoryChunk> = Vec::new();
4485 match tier.as_str() {
4486 "session" => {
4487 let Some(sid) = session_id.as_deref() else {
4488 return Ok(ToolResult {
4489 output: "tier=session requires session_id".to_string(),
4490 metadata: json!({"ok": false, "reason": "missing_session_scope"}),
4491 });
4492 };
4493 chunks.extend(manager.db().get_session_chunks(sid).await?);
4494 }
4495 "project" => {
4496 let Some(pid) = project_id.as_deref() else {
4497 return Ok(ToolResult {
4498 output: "tier=project requires project_id".to_string(),
4499 metadata: json!({"ok": false, "reason": "missing_project_scope"}),
4500 });
4501 };
4502 chunks.extend(manager.db().get_project_chunks(pid).await?);
4503 }
4504 "global" => {
4505 chunks.extend(manager.db().get_global_chunks(limit as i64).await?);
4506 }
4507 "all" => {
4508 if let Some(sid) = session_id.as_deref() {
4509 chunks.extend(manager.db().get_session_chunks(sid).await?);
4510 }
4511 if let Some(pid) = project_id.as_deref() {
4512 chunks.extend(manager.db().get_project_chunks(pid).await?);
4513 }
4514 if allow_global {
4515 chunks.extend(manager.db().get_global_chunks(limit as i64).await?);
4516 }
4517 }
4518 _ => {
4519 return Ok(ToolResult {
4520 output: "memory_list tier must be one of: session, project, global, all"
4521 .to_string(),
4522 metadata: json!({"ok": false, "reason": "invalid_tier"}),
4523 });
4524 }
4525 }
4526
4527 chunks.sort_by(|a, b| b.created_at.cmp(&a.created_at));
4528 chunks.truncate(limit);
4529 let rows = chunks
4530 .iter()
4531 .map(|chunk| {
4532 json!({
4533 "chunk_id": chunk.id,
4534 "tier": chunk.tier.to_string(),
4535 "session_id": chunk.session_id,
4536 "project_id": chunk.project_id,
4537 "source": chunk.source,
4538 "content": chunk.content,
4539 "created_at": chunk.created_at,
4540 "metadata": chunk.metadata,
4541 })
4542 })
4543 .collect::<Vec<_>>();
4544
4545 Ok(ToolResult {
4546 output: serde_json::to_string_pretty(&rows).unwrap_or_default(),
4547 metadata: json!({
4548 "ok": true,
4549 "count": rows.len(),
4550 "limit": limit,
4551 "tier": tier,
4552 "session_id": session_id,
4553 "project_id": project_id,
4554 "allow_global": allow_global,
4555 "db_path": db_path,
4556 }),
4557 })
4558 }
4559}
4560
4561struct MemoryDeleteTool;
4562#[async_trait]
4563impl Tool for MemoryDeleteTool {
4564 fn schema(&self) -> ToolSchema {
4565 tool_schema(
4566 "memory_delete",
4567 "Delete a stored memory chunk from session/project/global memory within the current allowed scope.",
4568 json!({
4569 "type":"object",
4570 "properties":{
4571 "chunk_id":{"type":"string"},
4572 "id":{"type":"string"},
4573 "tier":{"type":"string","enum":["session","project","global"]},
4574 "session_id":{"type":"string"},
4575 "project_id":{"type":"string"},
4576 "allow_global":{"type":"boolean"}
4577 },
4578 "required":["chunk_id"]
4579 }),
4580 )
4581 }
4582
4583 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
4584 let chunk_id = args
4585 .get("chunk_id")
4586 .or_else(|| args.get("id"))
4587 .and_then(|v| v.as_str())
4588 .map(str::trim)
4589 .unwrap_or("");
4590 if chunk_id.is_empty() {
4591 return Ok(ToolResult {
4592 output: "memory_delete requires chunk_id".to_string(),
4593 metadata: json!({"ok": false, "reason": "missing_chunk_id"}),
4594 });
4595 }
4596
4597 let session_id = memory_session_id(&args);
4598 let project_id = memory_project_id(&args);
4599 let allow_global = global_memory_enabled(&args);
4600
4601 let tier = match args
4602 .get("tier")
4603 .and_then(|v| v.as_str())
4604 .map(|s| s.trim().to_ascii_lowercase())
4605 {
4606 Some(t) if t == "session" => MemoryTier::Session,
4607 Some(t) if t == "project" => MemoryTier::Project,
4608 Some(t) if t == "global" => MemoryTier::Global,
4609 Some(_) => {
4610 return Ok(ToolResult {
4611 output: "memory_delete tier must be one of: session, project, global"
4612 .to_string(),
4613 metadata: json!({"ok": false, "reason": "invalid_tier"}),
4614 });
4615 }
4616 None => {
4617 if project_id.is_some() {
4618 MemoryTier::Project
4619 } else if session_id.is_some() {
4620 MemoryTier::Session
4621 } else if allow_global {
4622 MemoryTier::Global
4623 } else {
4624 return Ok(ToolResult {
4625 output: "memory_delete requires a current session/project context or global memory enabled by policy".to_string(),
4626 metadata: json!({"ok": false, "reason": "missing_scope"}),
4627 });
4628 }
4629 }
4630 };
4631
4632 if matches!(tier, MemoryTier::Session) && session_id.is_none() {
4633 return Ok(ToolResult {
4634 output: "tier=session requires session_id".to_string(),
4635 metadata: json!({"ok": false, "reason": "missing_session_scope"}),
4636 });
4637 }
4638 if matches!(tier, MemoryTier::Project) && project_id.is_none() {
4639 return Ok(ToolResult {
4640 output: "tier=project requires project_id".to_string(),
4641 metadata: json!({"ok": false, "reason": "missing_project_scope"}),
4642 });
4643 }
4644 if matches!(tier, MemoryTier::Global) && !allow_global {
4645 return Ok(ToolResult {
4646 output: "tier=global requires allow_global=true".to_string(),
4647 metadata: json!({"ok": false, "reason": "global_scope_disabled"}),
4648 });
4649 }
4650
4651 let db_path = resolve_memory_db_path(&args);
4652 let manager = MemoryManager::new(&db_path).await?;
4653 let deleted = manager
4654 .db()
4655 .delete_chunk(tier, chunk_id, project_id.as_deref(), session_id.as_deref())
4656 .await?;
4657
4658 if deleted == 0 {
4659 return Ok(ToolResult {
4660 output: format!("memory chunk `{chunk_id}` not found in {tier} memory"),
4661 metadata: json!({
4662 "ok": false,
4663 "reason": "not_found",
4664 "chunk_id": chunk_id,
4665 "tier": tier.to_string(),
4666 "session_id": session_id,
4667 "project_id": project_id,
4668 "allow_global": allow_global,
4669 "db_path": db_path,
4670 }),
4671 });
4672 }
4673
4674 Ok(ToolResult {
4675 output: format!("deleted memory chunk `{chunk_id}` from {tier} memory"),
4676 metadata: json!({
4677 "ok": true,
4678 "deleted": true,
4679 "chunk_id": chunk_id,
4680 "count": deleted,
4681 "tier": tier.to_string(),
4682 "session_id": session_id,
4683 "project_id": project_id,
4684 "allow_global": allow_global,
4685 "db_path": db_path,
4686 }),
4687 })
4688 }
4689}
4690
4691fn resolve_memory_db_path(args: &Value) -> PathBuf {
4692 if let Some(path) = args
4693 .get("__memory_db_path")
4694 .and_then(|v| v.as_str())
4695 .map(str::trim)
4696 .filter(|s| !s.is_empty())
4697 {
4698 return PathBuf::from(path);
4699 }
4700 if let Ok(path) = std::env::var("TANDEM_MEMORY_DB_PATH") {
4701 let trimmed = path.trim();
4702 if !trimmed.is_empty() {
4703 return PathBuf::from(trimmed);
4704 }
4705 }
4706 if let Ok(state_dir) = std::env::var("TANDEM_STATE_DIR") {
4707 let trimmed = state_dir.trim();
4708 if !trimmed.is_empty() {
4709 return PathBuf::from(trimmed).join("memory.sqlite");
4710 }
4711 }
4712 if let Some(data_dir) = dirs::data_dir() {
4713 return data_dir.join("tandem").join("memory.sqlite");
4714 }
4715 PathBuf::from("memory.sqlite")
4716}
4717
4718#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4719enum MemoryVisibleScope {
4720 Session,
4721 Project,
4722 Global,
4723}
4724
4725fn parse_memory_visible_scope(raw: &str) -> Option<MemoryVisibleScope> {
4726 match raw.trim().to_ascii_lowercase().as_str() {
4727 "session" => Some(MemoryVisibleScope::Session),
4728 "project" | "workspace" => Some(MemoryVisibleScope::Project),
4729 "global" => Some(MemoryVisibleScope::Global),
4730 _ => None,
4731 }
4732}
4733
4734fn memory_visible_scope(args: &Value) -> MemoryVisibleScope {
4735 if let Some(scope) = args
4736 .get("__memory_max_visible_scope")
4737 .and_then(|v| v.as_str())
4738 .and_then(parse_memory_visible_scope)
4739 {
4740 return scope;
4741 }
4742 if let Ok(raw) = std::env::var("TANDEM_MEMORY_MAX_VISIBLE_SCOPE") {
4743 if let Some(scope) = parse_memory_visible_scope(&raw) {
4744 return scope;
4745 }
4746 }
4747 MemoryVisibleScope::Global
4748}
4749
4750fn memory_session_id(args: &Value) -> Option<String> {
4751 args.get("session_id")
4752 .or_else(|| args.get("__session_id"))
4753 .and_then(|v| v.as_str())
4754 .map(str::trim)
4755 .filter(|s| !s.is_empty())
4756 .map(ToString::to_string)
4757}
4758
4759fn memory_project_id(args: &Value) -> Option<String> {
4760 args.get("project_id")
4761 .or_else(|| args.get("__project_id"))
4762 .and_then(|v| v.as_str())
4763 .map(str::trim)
4764 .filter(|s| !s.is_empty())
4765 .map(ToString::to_string)
4766}
4767
4768fn global_memory_enabled(args: &Value) -> bool {
4769 if memory_visible_scope(args) != MemoryVisibleScope::Global {
4770 return false;
4771 }
4772 if let Some(explicit) = args.get("allow_global").and_then(|v| v.as_bool()) {
4773 return explicit;
4774 }
4775 match std::env::var("TANDEM_ENABLE_GLOBAL_MEMORY") {
4776 Ok(raw) => !matches!(
4777 raw.trim().to_ascii_lowercase().as_str(),
4778 "0" | "false" | "no" | "off"
4779 ),
4780 Err(_) => true,
4781 }
4782}
4783
4784struct SkillTool;
4785#[async_trait]
4786impl Tool for SkillTool {
4787 fn schema(&self) -> ToolSchema {
4788 tool_schema(
4789 "skill",
4790 "List or load installed Tandem skills. Call without name to list available skills; provide name to load full SKILL.md content.",
4791 json!({"type":"object","properties":{"name":{"type":"string"}}}),
4792 )
4793 }
4794 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
4795 let workspace_root = std::env::current_dir().ok();
4796 let service = SkillService::for_workspace(workspace_root);
4797 let requested = args["name"].as_str().map(str::trim).unwrap_or("");
4798 let allowed_skills = parse_allowed_skills(&args);
4799
4800 if requested.is_empty() {
4801 let mut skills = service.list_skills().unwrap_or_default();
4802 if let Some(allowed) = &allowed_skills {
4803 skills.retain(|s| allowed.contains(&s.name));
4804 }
4805 if skills.is_empty() {
4806 return Ok(ToolResult {
4807 output: "No skills available.".to_string(),
4808 metadata: json!({"count": 0, "skills": []}),
4809 });
4810 }
4811 let mut lines = vec![
4812 "Available Tandem skills:".to_string(),
4813 "<available_skills>".to_string(),
4814 ];
4815 for skill in &skills {
4816 lines.push(" <skill>".to_string());
4817 lines.push(format!(" <name>{}</name>", skill.name));
4818 lines.push(format!(
4819 " <description>{}</description>",
4820 escape_xml_text(&skill.description)
4821 ));
4822 lines.push(format!(" <location>{}</location>", skill.path));
4823 lines.push(" </skill>".to_string());
4824 }
4825 lines.push("</available_skills>".to_string());
4826 return Ok(ToolResult {
4827 output: lines.join("\n"),
4828 metadata: json!({"count": skills.len(), "skills": skills}),
4829 });
4830 }
4831
4832 if let Some(allowed) = &allowed_skills {
4833 if !allowed.contains(requested) {
4834 let mut allowed_list = allowed.iter().cloned().collect::<Vec<_>>();
4835 allowed_list.sort();
4836 return Ok(ToolResult {
4837 output: format!(
4838 "Skill \"{}\" is not enabled for this agent. Enabled skills: {}",
4839 requested,
4840 allowed_list.join(", ")
4841 ),
4842 metadata: json!({"name": requested, "enabled": allowed_list}),
4843 });
4844 }
4845 }
4846
4847 let loaded = service.load_skill(requested).map_err(anyhow::Error::msg)?;
4848 let Some(skill) = loaded else {
4849 let available = service
4850 .list_skills()
4851 .unwrap_or_default()
4852 .into_iter()
4853 .map(|s| s.name)
4854 .collect::<Vec<_>>();
4855 return Ok(ToolResult {
4856 output: format!(
4857 "Skill \"{}\" not found. Available skills: {}",
4858 requested,
4859 if available.is_empty() {
4860 "none".to_string()
4861 } else {
4862 available.join(", ")
4863 }
4864 ),
4865 metadata: json!({"name": requested, "matches": [], "available": available}),
4866 });
4867 };
4868
4869 let files = skill
4870 .files
4871 .iter()
4872 .map(|f| format!("<file>{}</file>", f))
4873 .collect::<Vec<_>>()
4874 .join("\n");
4875 let output = [
4876 format!("<skill_content name=\"{}\">", skill.info.name),
4877 format!("# Skill: {}", skill.info.name),
4878 String::new(),
4879 skill.content.trim().to_string(),
4880 String::new(),
4881 format!("Base directory for this skill: {}", skill.base_dir),
4882 "Relative paths in this skill are resolved from this base directory.".to_string(),
4883 "Note: file list is sampled.".to_string(),
4884 String::new(),
4885 "<skill_files>".to_string(),
4886 files,
4887 "</skill_files>".to_string(),
4888 "</skill_content>".to_string(),
4889 ]
4890 .join("\n");
4891 Ok(ToolResult {
4892 output,
4893 metadata: json!({
4894 "name": skill.info.name,
4895 "dir": skill.base_dir,
4896 "path": skill.info.path
4897 }),
4898 })
4899 }
4900}
4901
4902fn escape_xml_text(input: &str) -> String {
4903 input
4904 .replace('&', "&")
4905 .replace('<', "<")
4906 .replace('>', ">")
4907}
4908
4909fn parse_allowed_skills(args: &Value) -> Option<HashSet<String>> {
4910 let values = args
4911 .get("allowed_skills")
4912 .or_else(|| args.get("allowedSkills"))
4913 .and_then(|v| v.as_array())?;
4914 let out = values
4915 .iter()
4916 .filter_map(|v| v.as_str())
4917 .map(str::trim)
4918 .filter(|s| !s.is_empty())
4919 .map(ToString::to_string)
4920 .collect::<HashSet<_>>();
4921 Some(out)
4922}
4923
4924struct ApplyPatchTool;
4925#[async_trait]
4926impl Tool for ApplyPatchTool {
4927 fn schema(&self) -> ToolSchema {
4928 tool_schema_with_capabilities(
4929 "apply_patch",
4930 "Apply a Codex-style patch in a git workspace, or validate patch text when git patching is unavailable",
4931 json!({"type":"object","properties":{"patchText":{"type":"string"}}}),
4932 apply_patch_capabilities(),
4933 )
4934 }
4935 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
4936 let patch = args["patchText"].as_str().unwrap_or("");
4937 let has_begin = patch.contains("*** Begin Patch");
4938 let has_end = patch.contains("*** End Patch");
4939 let patch_paths = extract_apply_patch_paths(patch);
4940 let file_ops = patch_paths.len();
4941 let valid = has_begin && has_end && file_ops > 0;
4942 if !valid {
4943 return Ok(ToolResult {
4944 output: "Invalid patch format. Expected Begin/End markers and at least one file operation."
4945 .to_string(),
4946 metadata: json!({"valid": false, "fileOps": file_ops}),
4947 });
4948 }
4949 let workspace_root =
4950 workspace_root_from_args(&args).unwrap_or_else(|| effective_cwd_from_args(&args));
4951 let git_root = resolve_git_root_for_dir(&workspace_root).await;
4952 if let Some(git_root) = git_root {
4953 let denied_paths = patch_paths
4954 .iter()
4955 .filter_map(|rel| {
4956 let resolved = git_root.join(rel);
4957 if is_within_workspace_root(&resolved, &workspace_root) {
4958 None
4959 } else {
4960 Some(rel.clone())
4961 }
4962 })
4963 .collect::<Vec<_>>();
4964 if !denied_paths.is_empty() {
4965 return Ok(ToolResult {
4966 output: format!(
4967 "patch denied by workspace policy for paths: {}",
4968 denied_paths.join(", ")
4969 ),
4970 metadata: json!({
4971 "valid": true,
4972 "applied": false,
4973 "reason": "path_outside_workspace",
4974 "paths": patch_paths
4975 }),
4976 });
4977 }
4978 let tmp_name = format!(
4979 "tandem-apply-patch-{}-{}.patch",
4980 std::process::id(),
4981 now_millis()
4982 );
4983 let patch_path = std::env::temp_dir().join(tmp_name);
4984 fs::write(&patch_path, patch).await?;
4985 let output = Command::new("git")
4986 .current_dir(&git_root)
4987 .arg("apply")
4988 .arg("--3way")
4989 .arg("--recount")
4990 .arg("--whitespace=nowarn")
4991 .arg(&patch_path)
4992 .output()
4993 .await?;
4994 let _ = fs::remove_file(&patch_path).await;
4995 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
4996 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
4997 let ok = output.status.success();
4998 return Ok(ToolResult {
4999 output: if ok {
5000 if stdout.is_empty() {
5001 "ok".to_string()
5002 } else {
5003 stdout.clone()
5004 }
5005 } else if stderr.is_empty() {
5006 "git apply failed".to_string()
5007 } else {
5008 stderr.clone()
5009 },
5010 metadata: json!({
5011 "valid": true,
5012 "applied": ok,
5013 "paths": patch_paths,
5014 "git_root": git_root.to_string_lossy(),
5015 "stdout": stdout,
5016 "stderr": stderr
5017 }),
5018 });
5019 }
5020 Ok(ToolResult {
5021 output: "Patch format validated, but no git workspace was detected. Use `edit` for existing files or `write` for new files in this workspace."
5022 .to_string(),
5023 metadata: json!({
5024 "valid": true,
5025 "applied": false,
5026 "reason": "git_workspace_unavailable",
5027 "paths": patch_paths
5028 }),
5029 })
5030 }
5031}
5032
5033fn extract_apply_patch_paths(patch: &str) -> Vec<String> {
5034 let mut paths = Vec::new();
5035 for line in patch.lines() {
5036 let trimmed = line.trim();
5037 let marker = if let Some(value) = trimmed.strip_prefix("*** Add File: ") {
5038 Some(value)
5039 } else if let Some(value) = trimmed.strip_prefix("*** Update File: ") {
5040 Some(value)
5041 } else {
5042 trimmed.strip_prefix("*** Delete File: ")
5043 };
5044 let Some(path) = marker.map(str::trim).filter(|value| !value.is_empty()) else {
5045 continue;
5046 };
5047 if !paths.iter().any(|existing| existing == path) {
5048 paths.push(path.to_string());
5049 }
5050 }
5051 paths
5052}
5053
5054async fn resolve_git_root_for_dir(dir: &Path) -> Option<PathBuf> {
5055 let output = Command::new("git")
5056 .current_dir(dir)
5057 .arg("rev-parse")
5058 .arg("--show-toplevel")
5059 .stdout(Stdio::piped())
5060 .stderr(Stdio::null())
5061 .output()
5062 .await
5063 .ok()?;
5064 if !output.status.success() {
5065 return None;
5066 }
5067 let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
5068 if root.is_empty() {
5069 None
5070 } else {
5071 Some(PathBuf::from(root))
5072 }
5073}
5074
5075fn now_millis() -> u128 {
5076 std::time::SystemTime::now()
5077 .duration_since(std::time::UNIX_EPOCH)
5078 .map(|value| value.as_millis())
5079 .unwrap_or(0)
5080}
5081
5082struct BatchTool;
5083#[async_trait]
5084impl Tool for BatchTool {
5085 fn schema(&self) -> ToolSchema {
5086 tool_schema(
5087 "batch",
5088 "Execute multiple tool calls sequentially",
5089 json!({
5090 "type":"object",
5091 "properties":{
5092 "tool_calls":{
5093 "type":"array",
5094 "items":{
5095 "type":"object",
5096 "properties":{
5097 "tool":{"type":"string"},
5098 "name":{"type":"string"},
5099 "args":{"type":"object"}
5100 }
5101 }
5102 }
5103 }
5104 }),
5105 )
5106 }
5107 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
5108 self.execute_with_cancel(args, CancellationToken::new())
5109 .await
5110 }
5111
5112 async fn execute_with_cancel(
5113 &self,
5114 args: Value,
5115 cancel: CancellationToken,
5116 ) -> anyhow::Result<ToolResult> {
5117 self.execute_with_progress(args, cancel, None).await
5118 }
5119
5120 async fn execute_with_progress(
5121 &self,
5122 args: Value,
5123 cancel: CancellationToken,
5124 progress: Option<SharedToolProgressSink>,
5125 ) -> anyhow::Result<ToolResult> {
5126 let calls = args["tool_calls"].as_array().cloned().unwrap_or_default();
5127 let registry = ToolRegistry::new();
5128 let mut outputs = Vec::new();
5129 for call in calls.iter().take(20) {
5130 if cancel.is_cancelled() {
5131 break;
5132 }
5133 let Some(tool) = resolve_batch_call_tool_name(call) else {
5134 continue;
5135 };
5136 if tool.is_empty() || tool == "batch" {
5137 continue;
5138 }
5139 let call_args = call.get("args").cloned().unwrap_or_else(|| json!({}));
5140 let mut result = registry
5141 .execute_with_cancel_and_progress(
5142 &tool,
5143 call_args.clone(),
5144 cancel.clone(),
5145 progress.clone(),
5146 )
5147 .await?;
5148 if result.output.starts_with("Unknown tool:") {
5149 if let Some(fallback_name) = call
5150 .get("name")
5151 .and_then(|v| v.as_str())
5152 .map(str::trim)
5153 .filter(|s| !s.is_empty() && *s != tool)
5154 {
5155 result = registry
5156 .execute_with_cancel_and_progress(
5157 fallback_name,
5158 call_args,
5159 cancel.clone(),
5160 progress.clone(),
5161 )
5162 .await?;
5163 }
5164 }
5165 outputs.push(json!({
5166 "tool": tool,
5167 "output": result.output,
5168 "metadata": result.metadata
5169 }));
5170 }
5171 let count = outputs.len();
5172 Ok(ToolResult {
5173 output: serde_json::to_string_pretty(&outputs).unwrap_or_default(),
5174 metadata: json!({"count": count}),
5175 })
5176 }
5177}
5178
5179struct LspTool;
5180#[async_trait]
5181impl Tool for LspTool {
5182 fn schema(&self) -> ToolSchema {
5183 tool_schema(
5184 "lsp",
5185 "LSP-like workspace diagnostics and symbol operations",
5186 json!({"type":"object","properties":{"operation":{"type":"string"},"filePath":{"type":"string"},"symbol":{"type":"string"},"query":{"type":"string"}}}),
5187 )
5188 }
5189 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
5190 let operation = args["operation"].as_str().unwrap_or("symbols");
5191 let workspace_root =
5192 workspace_root_from_args(&args).unwrap_or_else(|| effective_cwd_from_args(&args));
5193 let output = match operation {
5194 "diagnostics" => {
5195 let path = args["filePath"].as_str().unwrap_or("");
5196 match resolve_tool_path(path, &args) {
5197 Some(resolved_path) => {
5198 diagnostics_for_path(&resolved_path.to_string_lossy()).await
5199 }
5200 None => "missing or unsafe filePath".to_string(),
5201 }
5202 }
5203 "definition" => {
5204 let symbol = args["symbol"].as_str().unwrap_or("");
5205 find_symbol_definition(symbol, &workspace_root).await
5206 }
5207 "references" => {
5208 let symbol = args["symbol"].as_str().unwrap_or("");
5209 find_symbol_references(symbol, &workspace_root).await
5210 }
5211 _ => {
5212 let query = args["query"]
5213 .as_str()
5214 .or_else(|| args["symbol"].as_str())
5215 .unwrap_or("");
5216 list_symbols(query, &workspace_root).await
5217 }
5218 };
5219 Ok(ToolResult {
5220 output,
5221 metadata: json!({"operation": operation, "workspace_root": workspace_root.to_string_lossy()}),
5222 })
5223 }
5224}
5225
5226#[allow(dead_code)]
5227fn _safe_path(path: &str) -> PathBuf {
5228 PathBuf::from(path)
5229}
5230
5231static TODO_SEQ: AtomicU64 = AtomicU64::new(1);
5232
5233fn normalize_todos(items: Vec<Value>) -> Vec<Value> {
5234 items
5235 .into_iter()
5236 .filter_map(|item| {
5237 let obj = item.as_object()?;
5238 let content = obj
5239 .get("content")
5240 .and_then(|v| v.as_str())
5241 .or_else(|| obj.get("text").and_then(|v| v.as_str()))
5242 .unwrap_or("")
5243 .trim()
5244 .to_string();
5245 if content.is_empty() {
5246 return None;
5247 }
5248 let id = obj
5249 .get("id")
5250 .and_then(|v| v.as_str())
5251 .filter(|s| !s.trim().is_empty())
5252 .map(ToString::to_string)
5253 .unwrap_or_else(|| {
5254 format!("todo-{}", TODO_SEQ.fetch_add(1, AtomicOrdering::Relaxed))
5255 });
5256 let status = obj
5257 .get("status")
5258 .and_then(|v| v.as_str())
5259 .filter(|s| !s.trim().is_empty())
5260 .map(ToString::to_string)
5261 .unwrap_or_else(|| "pending".to_string());
5262 Some(json!({"id": id, "content": content, "status": status}))
5263 })
5264 .collect()
5265}
5266
5267async fn diagnostics_for_path(path: &str) -> String {
5268 let Ok(content) = fs::read_to_string(path).await else {
5269 return "File not found".to_string();
5270 };
5271 let mut issues = Vec::new();
5272 let mut balance = 0i64;
5273 for (idx, line) in content.lines().enumerate() {
5274 for ch in line.chars() {
5275 if ch == '{' {
5276 balance += 1;
5277 } else if ch == '}' {
5278 balance -= 1;
5279 }
5280 }
5281 if line.contains("TODO") {
5282 issues.push(format!("{path}:{}: TODO marker", idx + 1));
5283 }
5284 }
5285 if balance != 0 {
5286 issues.push(format!("{path}:1: Unbalanced braces"));
5287 }
5288 if issues.is_empty() {
5289 "No diagnostics.".to_string()
5290 } else {
5291 issues.join("\n")
5292 }
5293}
5294
5295async fn list_symbols(query: &str, root: &Path) -> String {
5296 let query = query.to_lowercase();
5297 let rust_fn = Regex::new(r"^\s*(pub\s+)?(async\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)")
5298 .unwrap_or_else(|_| Regex::new("$^").expect("regex"));
5299 let mut out = Vec::new();
5300 for entry in WalkBuilder::new(root).build().flatten() {
5301 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
5302 continue;
5303 }
5304 let path = entry.path();
5305 let ext = path.extension().and_then(|v| v.to_str()).unwrap_or("");
5306 if !matches!(ext, "rs" | "ts" | "tsx" | "js" | "jsx" | "py") {
5307 continue;
5308 }
5309 if let Ok(content) = fs::read_to_string(path).await {
5310 for (idx, line) in content.lines().enumerate() {
5311 if let Some(captures) = rust_fn.captures(line) {
5312 let name = captures
5313 .get(3)
5314 .map(|m| m.as_str().to_string())
5315 .unwrap_or_default();
5316 if query.is_empty() || name.to_lowercase().contains(&query) {
5317 out.push(format!("{}:{}:fn {}", path.display(), idx + 1, name));
5318 if out.len() >= 100 {
5319 return out.join("\n");
5320 }
5321 }
5322 }
5323 }
5324 }
5325 }
5326 out.join("\n")
5327}
5328
5329async fn find_symbol_definition(symbol: &str, root: &Path) -> String {
5330 if symbol.trim().is_empty() {
5331 return "missing symbol".to_string();
5332 }
5333 let listed = list_symbols(symbol, root).await;
5334 listed
5335 .lines()
5336 .find(|line| line.ends_with(&format!("fn {symbol}")))
5337 .map(ToString::to_string)
5338 .unwrap_or_else(|| "symbol not found".to_string())
5339}
5340
5341#[cfg(test)]
5342mod tests {
5343 use super::*;
5344 use std::collections::HashSet;
5345 use std::path::PathBuf;
5346 use std::sync::{Arc, Mutex, OnceLock};
5347 use tandem_types::ToolProgressSink;
5348 use tempfile::TempDir;
5349 use tokio::fs;
5350 use tokio_util::sync::CancellationToken;
5351
5352 #[derive(Clone, Default)]
5353 struct RecordingProgressSink {
5354 events: Arc<Mutex<Vec<ToolProgressEvent>>>,
5355 }
5356
5357 impl ToolProgressSink for RecordingProgressSink {
5358 fn publish(&self, event: ToolProgressEvent) {
5359 self.events.lock().expect("progress lock").push(event);
5360 }
5361 }
5362
5363 struct TestTool {
5364 schema: ToolSchema,
5365 }
5366
5367 #[async_trait]
5368 impl Tool for TestTool {
5369 fn schema(&self) -> ToolSchema {
5370 self.schema.clone()
5371 }
5372
5373 async fn execute(&self, _args: Value) -> anyhow::Result<ToolResult> {
5374 Ok(ToolResult {
5375 output: "ok".to_string(),
5376 metadata: json!({}),
5377 })
5378 }
5379
5380 async fn execute_with_cancel(
5381 &self,
5382 args: Value,
5383 _cancel: CancellationToken,
5384 ) -> anyhow::Result<ToolResult> {
5385 self.execute(args).await
5386 }
5387 }
5388
5389 fn search_env_lock() -> &'static Mutex<()> {
5390 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
5391 LOCK.get_or_init(|| Mutex::new(()))
5392 }
5393
5394 fn clear_search_env() {
5395 std::env::remove_var("TANDEM_SEARCH_BACKEND");
5396 std::env::remove_var("TANDEM_SEARCH_URL");
5397 std::env::remove_var("TANDEM_SEARXNG_URL");
5398 std::env::remove_var("TANDEM_SEARXNG_ENGINES");
5399 std::env::remove_var("TANDEM_SEARCH_TIMEOUT_MS");
5400 std::env::remove_var("TANDEM_EXA_API_KEY");
5401 std::env::remove_var("TANDEM_EXA_SEARCH_API_KEY");
5402 std::env::remove_var("EXA_API_KEY");
5403 std::env::remove_var("TANDEM_BRAVE_SEARCH_API_KEY");
5404 std::env::remove_var("BRAVE_SEARCH_API_KEY");
5405 }
5406
5407 #[test]
5408 fn validator_rejects_array_without_items() {
5409 let schemas = vec![ToolSchema::new(
5410 "bad",
5411 "bad schema",
5412 json!({
5413 "type":"object",
5414 "properties":{"todos":{"type":"array"}}
5415 }),
5416 )];
5417 let err = validate_tool_schemas(&schemas).expect_err("expected schema validation failure");
5418 assert_eq!(err.tool_name, "bad");
5419 assert!(err.path.contains("properties.todos"));
5420 }
5421
5422 #[tokio::test]
5423 async fn registry_schemas_are_unique_and_valid() {
5424 let registry = ToolRegistry::new();
5425 let schemas = registry.list().await;
5426 validate_tool_schemas(&schemas).expect("registry tool schemas should validate");
5427 let unique = schemas
5428 .iter()
5429 .map(|schema| schema.name.as_str())
5430 .collect::<HashSet<_>>();
5431 assert_eq!(
5432 unique.len(),
5433 schemas.len(),
5434 "tool schemas must be unique by name"
5435 );
5436 }
5437
5438 #[tokio::test]
5439 async fn core_tool_schemas_include_expected_capabilities() {
5440 let registry = ToolRegistry::new();
5441 let schemas = registry.list().await;
5442 let schema_by_name = schemas
5443 .iter()
5444 .map(|schema| (schema.name.as_str(), schema))
5445 .collect::<HashMap<_, _>>();
5446
5447 let read = schema_by_name.get("read").expect("read tool");
5448 assert!(read.capabilities.reads_workspace);
5449 assert!(read.capabilities.preferred_for_discovery);
5450 assert_eq!(
5451 read.capabilities.effects,
5452 vec![tandem_types::ToolEffect::Read]
5453 );
5454
5455 let write = schema_by_name.get("write").expect("write tool");
5456 assert!(write.capabilities.writes_workspace);
5457 assert!(write.capabilities.requires_verification);
5458 assert_eq!(
5459 write.capabilities.effects,
5460 vec![tandem_types::ToolEffect::Write]
5461 );
5462
5463 let grep = schema_by_name.get("grep").expect("grep tool");
5464 assert!(grep.capabilities.reads_workspace);
5465 assert!(grep.capabilities.preferred_for_discovery);
5466 assert_eq!(
5467 grep.capabilities.effects,
5468 vec![tandem_types::ToolEffect::Search]
5469 );
5470
5471 let bash = schema_by_name.get("bash").expect("bash tool");
5472 assert!(bash.capabilities.destructive);
5473 assert!(bash.capabilities.network_access);
5474 assert_eq!(
5475 bash.capabilities.effects,
5476 vec![tandem_types::ToolEffect::Execute]
5477 );
5478
5479 let webfetch = schema_by_name.get("webfetch").expect("webfetch tool");
5480 assert!(webfetch.capabilities.network_access);
5481 assert!(webfetch.capabilities.preferred_for_discovery);
5482 assert_eq!(
5483 webfetch.capabilities.effects,
5484 vec![tandem_types::ToolEffect::Fetch]
5485 );
5486
5487 let apply_patch = schema_by_name.get("apply_patch").expect("apply_patch tool");
5488 assert!(apply_patch.capabilities.reads_workspace);
5489 assert!(apply_patch.capabilities.writes_workspace);
5490 assert!(apply_patch.capabilities.requires_verification);
5491 assert_eq!(
5492 apply_patch.capabilities.effects,
5493 vec![tandem_types::ToolEffect::Patch]
5494 );
5495 }
5496
5497 fn grep_args(root: &Path, pattern: &str) -> Value {
5498 let root = root.to_string_lossy().to_string();
5499 json!({
5500 "pattern": pattern,
5501 "path": root.clone(),
5502 "__workspace_root": root.clone(),
5503 "__effective_cwd": root,
5504 })
5505 }
5506
5507 #[tokio::test]
5508 async fn grep_tool_reports_matches_while_skipping_ignored_and_binary_paths() {
5509 let tempdir = TempDir::new().expect("tempdir");
5510 let root = tempdir.path();
5511 let visible = root.join("src").join("nested").join("notes.txt");
5512 let ignored = root.join(".tandem").join("private").join("secret.txt");
5513 let binary = root.join("binary.bin");
5514
5515 std::fs::create_dir_all(visible.parent().expect("visible parent"))
5516 .expect("create visible dir");
5517 std::fs::create_dir_all(ignored.parent().expect("ignored parent"))
5518 .expect("create ignored dir");
5519 std::fs::write(&visible, "first line\nneedle here\nlast line").expect("write visible file");
5520 std::fs::write(&ignored, "needle should stay hidden").expect("write ignored file");
5521 std::fs::write(&binary, b"\0needle after null\n").expect("write binary file");
5522
5523 let tool = GrepTool;
5524 let result = tool
5525 .execute(grep_args(root, "needle"))
5526 .await
5527 .expect("grep result");
5528
5529 assert_eq!(result.metadata["count"], json!(1));
5530 assert_eq!(
5531 result.output,
5532 format!("{}:2:needle here", visible.display())
5533 );
5534 assert!(!result.output.contains(".tandem/private/secret.txt"));
5535 }
5536
5537 #[tokio::test]
5538 async fn grep_tool_streams_chunk_and_done_events() {
5539 let tempdir = TempDir::new().expect("tempdir");
5540 let root = tempdir.path();
5541 let first = root.join("a.txt");
5542 let second = root.join("b.txt");
5543
5544 std::fs::write(
5545 &first,
5546 [
5547 "needle a1",
5548 "needle a2",
5549 "needle a3",
5550 "needle a4",
5551 "needle a5",
5552 "needle a6",
5553 ]
5554 .join("\n"),
5555 )
5556 .expect("write first file");
5557 std::fs::write(
5558 &second,
5559 [
5560 "needle b1",
5561 "needle b2",
5562 "needle b3",
5563 "needle b4",
5564 "needle b5",
5565 "needle b6",
5566 ]
5567 .join("\n"),
5568 )
5569 .expect("write second file");
5570
5571 let sink = RecordingProgressSink::default();
5572 let events = Arc::clone(&sink.events);
5573 let progress: SharedToolProgressSink = Arc::new(sink);
5574
5575 let tool = GrepTool;
5576 let result = tool
5577 .execute_with_progress(
5578 grep_args(root, "needle"),
5579 CancellationToken::new(),
5580 Some(progress),
5581 )
5582 .await
5583 .expect("grep result");
5584
5585 assert_eq!(result.metadata["count"], json!(12));
5586 let lines = result.output.lines().collect::<Vec<_>>();
5587 assert_eq!(lines.len(), 12);
5588 assert!(lines[0].starts_with(&first.display().to_string()));
5589 assert!(lines[11].starts_with(&second.display().to_string()));
5590
5591 let events = events.lock().expect("events").clone();
5592 assert!(!events.is_empty());
5593 assert!(events
5594 .iter()
5595 .any(|event| event.event_type == "tool.search.chunk"));
5596 let done = events
5597 .iter()
5598 .rev()
5599 .find(|event| event.event_type == "tool.search.done")
5600 .expect("done event");
5601 assert_eq!(done.properties["count"], json!(12));
5602 assert_eq!(done.properties["tool"], json!("grep"));
5603 }
5604
5605 #[tokio::test]
5606 async fn grep_tool_caps_results_at_100_hits() {
5607 let tempdir = TempDir::new().expect("tempdir");
5608 let root = tempdir.path();
5609 let source = root.join("many.txt");
5610 let lines = (1..=120)
5611 .map(|idx| format!("match line {}", idx))
5612 .collect::<Vec<_>>()
5613 .join("\n");
5614 std::fs::write(&source, lines).expect("write source file");
5615
5616 let tool = GrepTool;
5617 let result = tool
5618 .execute(grep_args(root, "match"))
5619 .await
5620 .expect("grep result");
5621
5622 assert_eq!(result.metadata["count"], json!(100));
5623 assert_eq!(result.output.lines().count(), 100);
5624 assert!(result.output.contains("match line 100"));
5625 assert!(!result.output.contains("match line 101"));
5626 }
5627
5628 #[tokio::test]
5629 async fn grep_tool_rejects_invalid_regex_patterns() {
5630 let tempdir = TempDir::new().expect("tempdir");
5631 let root = tempdir.path();
5632 std::fs::write(root.join("notes.txt"), "needle").expect("write file");
5633
5634 let tool = GrepTool;
5635 let err = tool.execute(grep_args(root, "(")).await;
5636
5637 assert!(err.is_err(), "expected invalid regex to fail");
5638 }
5639
5640 #[tokio::test]
5641 async fn mcp_server_names_returns_unique_sorted_names() {
5642 let registry = ToolRegistry::new();
5643 registry
5644 .register_tool(
5645 "mcp.notion.search_pages".to_string(),
5646 Arc::new(TestTool {
5647 schema: ToolSchema::new("mcp.notion.search_pages", "search", json!({})),
5648 }),
5649 )
5650 .await;
5651 registry
5652 .register_tool(
5653 "mcp.github.list_prs".to_string(),
5654 Arc::new(TestTool {
5655 schema: ToolSchema::new("mcp.github.list_prs", "list", json!({})),
5656 }),
5657 )
5658 .await;
5659 registry
5660 .register_tool(
5661 "mcp.github.get_pr".to_string(),
5662 Arc::new(TestTool {
5663 schema: ToolSchema::new("mcp.github.get_pr", "get", json!({})),
5664 }),
5665 )
5666 .await;
5667
5668 let servers = registry.mcp_server_names().await;
5669 assert_eq!(servers, vec!["github".to_string(), "notion".to_string()]);
5670 }
5671
5672 #[tokio::test]
5673 async fn unregister_by_prefix_removes_index_vectors_for_removed_tools() {
5674 let registry = ToolRegistry::new();
5675 registry
5676 .register_tool(
5677 "mcp.test.search".to_string(),
5678 Arc::new(TestTool {
5679 schema: ToolSchema::new("mcp.test.search", "search", json!({})),
5680 }),
5681 )
5682 .await;
5683 registry
5684 .register_tool(
5685 "mcp.test.get".to_string(),
5686 Arc::new(TestTool {
5687 schema: ToolSchema::new("mcp.test.get", "get", json!({})),
5688 }),
5689 )
5690 .await;
5691
5692 registry
5693 .tool_vectors
5694 .write()
5695 .await
5696 .insert("mcp.test.search".to_string(), vec![1.0, 0.0, 0.0]);
5697 registry
5698 .tool_vectors
5699 .write()
5700 .await
5701 .insert("mcp.test.get".to_string(), vec![0.0, 1.0, 0.0]);
5702
5703 let removed = registry.unregister_by_prefix("mcp.test.").await;
5704 assert_eq!(removed, 2);
5705 let vectors = registry.tool_vectors.read().await;
5706 assert!(!vectors.contains_key("mcp.test.search"));
5707 assert!(!vectors.contains_key("mcp.test.get"));
5708 }
5709
5710 #[test]
5711 fn websearch_query_extraction_accepts_aliases_and_nested_shapes() {
5712 let direct = json!({"query":"meaning of life"});
5713 assert_eq!(
5714 extract_websearch_query(&direct).as_deref(),
5715 Some("meaning of life")
5716 );
5717
5718 let alias = json!({"q":"hello"});
5719 assert_eq!(extract_websearch_query(&alias).as_deref(), Some("hello"));
5720
5721 let nested = json!({"arguments":{"search_query":"rust tokio"}});
5722 assert_eq!(
5723 extract_websearch_query(&nested).as_deref(),
5724 Some("rust tokio")
5725 );
5726
5727 let as_string = json!("find docs");
5728 assert_eq!(
5729 extract_websearch_query(&as_string).as_deref(),
5730 Some("find docs")
5731 );
5732
5733 let malformed = json!({"query":"websearch query</arg_key><arg_value>taj card what is it benefits how to apply</arg_value>"});
5734 assert_eq!(
5735 extract_websearch_query(&malformed).as_deref(),
5736 Some("taj card what is it benefits how to apply")
5737 );
5738 }
5739
5740 #[test]
5741 fn websearch_limit_extraction_clamps_and_reads_nested_fields() {
5742 assert_eq!(extract_websearch_limit(&json!({"limit": 100})), Some(10));
5743 assert_eq!(
5744 extract_websearch_limit(&json!({"arguments":{"numResults": 0}})),
5745 Some(1)
5746 );
5747 assert_eq!(
5748 extract_websearch_limit(&json!({"input":{"num_results": 6}})),
5749 Some(6)
5750 );
5751 }
5752
5753 #[test]
5754 fn search_backend_defaults_to_searxng_when_configured() {
5755 let _guard = search_env_lock().lock().expect("env lock");
5756 clear_search_env();
5757 std::env::set_var("TANDEM_SEARXNG_URL", "http://localhost:8080");
5758
5759 let backend = SearchBackend::from_env();
5760
5761 match backend {
5762 SearchBackend::Searxng { base_url, .. } => {
5763 assert_eq!(base_url, "http://localhost:8080");
5764 }
5765 other => panic!("expected searxng backend, got {other:?}"),
5766 }
5767
5768 clear_search_env();
5769 }
5770
5771 #[test]
5772 fn search_backend_defaults_to_tandem_when_search_url_configured() {
5773 let _guard = search_env_lock().lock().expect("env lock");
5774 clear_search_env();
5775 std::env::set_var("TANDEM_SEARCH_URL", "https://search.tandem.ac");
5776
5777 let backend = SearchBackend::from_env();
5778
5779 match backend {
5780 SearchBackend::Tandem { base_url, .. } => {
5781 assert_eq!(base_url, "https://search.tandem.ac");
5782 }
5783 other => panic!("expected tandem backend, got {other:?}"),
5784 }
5785
5786 clear_search_env();
5787 }
5788
5789 #[test]
5790 fn search_backend_explicit_auto_is_supported() {
5791 let _guard = search_env_lock().lock().expect("env lock");
5792 clear_search_env();
5793 std::env::set_var("TANDEM_SEARCH_BACKEND", "auto");
5794 std::env::set_var("TANDEM_BRAVE_SEARCH_API_KEY", "brave-test-key");
5795 std::env::set_var("TANDEM_EXA_API_KEY", "exa-test-key");
5796
5797 let backend = SearchBackend::from_env();
5798
5799 match backend {
5800 SearchBackend::Auto { backends } => {
5801 assert_eq!(backends.len(), 2);
5802 assert!(matches!(backends[0], SearchBackend::Brave { .. }));
5803 assert!(matches!(backends[1], SearchBackend::Exa { .. }));
5804 }
5805 other => panic!("expected auto backend, got {other:?}"),
5806 }
5807
5808 clear_search_env();
5809 }
5810
5811 #[test]
5812 fn search_backend_implicit_auto_failover_when_multiple_backends_are_configured() {
5813 let _guard = search_env_lock().lock().expect("env lock");
5814 clear_search_env();
5815 std::env::set_var("TANDEM_BRAVE_SEARCH_API_KEY", "brave-test-key");
5816 std::env::set_var("TANDEM_EXA_API_KEY", "exa-test-key");
5817
5818 let backend = SearchBackend::from_env();
5819
5820 match backend {
5821 SearchBackend::Auto { backends } => {
5822 assert_eq!(backends.len(), 2);
5823 assert!(matches!(backends[0], SearchBackend::Brave { .. }));
5824 assert!(matches!(backends[1], SearchBackend::Exa { .. }));
5825 }
5826 other => panic!("expected auto backend, got {other:?}"),
5827 }
5828
5829 clear_search_env();
5830 }
5831
5832 #[test]
5833 fn search_backend_supports_legacy_exa_env_key() {
5834 let _guard = search_env_lock().lock().expect("env lock");
5835 clear_search_env();
5836 std::env::set_var("TANDEM_SEARCH_BACKEND", "exa");
5837 std::env::set_var("TANDEM_EXA_SEARCH_API_KEY", "legacy-exa-test-key");
5838
5839 let backend = SearchBackend::from_env();
5840
5841 match backend {
5842 SearchBackend::Exa { api_key, .. } => {
5843 assert_eq!(api_key, "legacy-exa-test-key");
5844 }
5845 other => panic!("expected exa backend, got {other:?}"),
5846 }
5847
5848 clear_search_env();
5849 }
5850
5851 #[test]
5852 fn normalize_brave_results_accepts_standard_web_payload_rows() {
5853 let raw = vec![json!({
5854 "title": "Agentic workflows",
5855 "url": "https://example.com/agentic",
5856 "description": "A practical overview of agentic workflows.",
5857 "profile": {
5858 "long_name": "example.com"
5859 }
5860 })];
5861
5862 let results = normalize_brave_results(&raw, 5);
5863
5864 assert_eq!(results.len(), 1);
5865 assert_eq!(results[0].title, "Agentic workflows");
5866 assert_eq!(results[0].url, "https://example.com/agentic");
5867 assert_eq!(
5868 results[0].snippet,
5869 "A practical overview of agentic workflows."
5870 );
5871 assert_eq!(results[0].source, "brave:example.com");
5872 }
5873
5874 #[test]
5875 fn search_backend_explicit_none_disables_websearch() {
5876 let _guard = search_env_lock().lock().expect("env lock");
5877 clear_search_env();
5878 std::env::set_var("TANDEM_SEARCH_BACKEND", "none");
5879 std::env::set_var("TANDEM_SEARXNG_URL", "http://localhost:8080");
5880
5881 let backend = SearchBackend::from_env();
5882
5883 assert!(matches!(backend, SearchBackend::Disabled { .. }));
5884
5885 clear_search_env();
5886 }
5887
5888 #[tokio::test]
5889 async fn tool_registry_includes_websearch_by_default() {
5890 let _guard = search_env_lock().lock().expect("env lock");
5891 clear_search_env();
5892
5893 let registry = ToolRegistry::new();
5894 let names = registry
5895 .list()
5896 .await
5897 .into_iter()
5898 .map(|schema| schema.name)
5899 .collect::<Vec<_>>();
5900
5901 assert!(names.iter().any(|name| name == "websearch"));
5902
5903 clear_search_env();
5904 }
5905
5906 #[tokio::test]
5907 async fn tool_registry_omits_websearch_when_search_backend_explicitly_disabled() {
5908 let _guard = search_env_lock().lock().expect("env lock");
5909 clear_search_env();
5910 std::env::set_var("TANDEM_SEARCH_BACKEND", "none");
5911
5912 let registry = ToolRegistry::new();
5913 let names = registry
5914 .list()
5915 .await
5916 .into_iter()
5917 .map(|schema| schema.name)
5918 .collect::<Vec<_>>();
5919
5920 assert!(!names.iter().any(|name| name == "websearch"));
5921
5922 clear_search_env();
5923 }
5924
5925 #[test]
5926 fn normalize_searxng_results_preserves_title_url_and_engine() {
5927 let results = normalize_searxng_results(
5928 &[json!({
5929 "title": "Tandem Docs",
5930 "url": "https://docs.tandem.ac/",
5931 "content": "Official documentation for Tandem.",
5932 "engine": "duckduckgo"
5933 })],
5934 8,
5935 );
5936
5937 assert_eq!(results.len(), 1);
5938 assert_eq!(results[0].title, "Tandem Docs");
5939 assert_eq!(results[0].url, "https://docs.tandem.ac/");
5940 assert_eq!(results[0].snippet, "Official documentation for Tandem.");
5941 assert_eq!(results[0].source, "searxng:duckduckgo");
5942 }
5943
5944 #[test]
5945 fn test_html_stripping_and_markdown_reduction() {
5946 let html = r#"
5947 <!DOCTYPE html>
5948 <html>
5949 <head>
5950 <title>Test Page</title>
5951 <style>
5952 body { color: red; }
5953 </style>
5954 <script>
5955 console.log("noisy script");
5956 </script>
5957 </head>
5958 <body>
5959 <h1>Hello World</h1>
5960 <p>This is a <a href="https://example.com">link</a>.</p>
5961 <noscript>Enable JS</noscript>
5962 </body>
5963 </html>
5964 "#;
5965
5966 let cleaned = strip_html_noise(html);
5967 assert!(!cleaned.contains("noisy script"));
5968 assert!(!cleaned.contains("color: red"));
5969 assert!(!cleaned.contains("Enable JS"));
5970 assert!(cleaned.contains("Hello World"));
5971
5972 let markdown = html2md::parse_html(&cleaned);
5973 let text = markdown_to_text(&markdown);
5974
5975 let raw_len = html.len();
5977 let md_len = markdown.len();
5979
5980 println!("Raw: {}, Markdown: {}", raw_len, md_len);
5981 assert!(
5982 md_len < raw_len / 2,
5983 "Markdown should be < 50% of raw HTML size"
5984 );
5985 assert!(text.contains("Hello World"));
5986 assert!(text.contains("link"));
5987 }
5988
5989 #[test]
5990 fn memory_scope_defaults_to_hidden_context() {
5991 let args = json!({
5992 "__session_id": "session-123",
5993 "__project_id": "workspace-abc"
5994 });
5995 assert_eq!(memory_session_id(&args).as_deref(), Some("session-123"));
5996 assert_eq!(memory_project_id(&args).as_deref(), Some("workspace-abc"));
5997 assert!(global_memory_enabled(&args));
5998 }
5999
6000 #[test]
6001 fn memory_scope_policy_can_disable_global_visibility() {
6002 let args = json!({
6003 "__session_id": "session-123",
6004 "__project_id": "workspace-abc",
6005 "__memory_max_visible_scope": "project"
6006 });
6007 assert_eq!(memory_visible_scope(&args), MemoryVisibleScope::Project);
6008 assert!(!global_memory_enabled(&args));
6009 }
6010
6011 #[test]
6012 fn memory_db_path_ignores_public_db_path_arg() {
6013 std::env::set_var("TANDEM_MEMORY_DB_PATH", "/tmp/global-memory.sqlite");
6014 let resolved = resolve_memory_db_path(&json!({
6015 "db_path": "/home/user123/tandem"
6016 }));
6017 assert_eq!(resolved, PathBuf::from("/tmp/global-memory.sqlite"));
6018 std::env::remove_var("TANDEM_MEMORY_DB_PATH");
6019 }
6020
6021 #[test]
6022 fn memory_db_path_accepts_hidden_override() {
6023 std::env::set_var("TANDEM_MEMORY_DB_PATH", "/tmp/global-memory.sqlite");
6024 let resolved = resolve_memory_db_path(&json!({
6025 "__memory_db_path": "/tmp/internal-memory.sqlite",
6026 "db_path": "/home/user123/tandem"
6027 }));
6028 assert_eq!(resolved, PathBuf::from("/tmp/internal-memory.sqlite"));
6029 std::env::remove_var("TANDEM_MEMORY_DB_PATH");
6030 }
6031
6032 #[tokio::test]
6033 async fn memory_search_uses_global_by_default() {
6034 let tool = MemorySearchTool;
6035 let result = tool
6036 .execute(json!({
6037 "query": "global pattern",
6038 "tier": "global"
6039 }))
6040 .await
6041 .expect("memory_search should return ToolResult");
6042 assert!(
6043 result.output.contains("memory database not found")
6044 || result.output.contains("memory embeddings unavailable")
6045 );
6046 assert_eq!(result.metadata["ok"], json!(false));
6047 let reason = result
6048 .metadata
6049 .get("reason")
6050 .and_then(|v| v.as_str())
6051 .unwrap_or_default();
6052 assert!(matches!(
6053 reason,
6054 "memory_db_missing" | "embeddings_unavailable"
6055 ));
6056 }
6057
6058 #[tokio::test]
6059 async fn memory_store_uses_hidden_project_scope_by_default() {
6060 let tool = MemoryStoreTool;
6061 let result = tool
6062 .execute(json!({
6063 "content": "remember this",
6064 "__session_id": "session-123",
6065 "__project_id": "workspace-abc"
6066 }))
6067 .await
6068 .expect("memory_store should return ToolResult");
6069 assert!(
6070 result.output.contains("memory embeddings unavailable")
6071 || result.output.contains("memory database not found")
6072 );
6073 let reason = result
6074 .metadata
6075 .get("reason")
6076 .and_then(|v| v.as_str())
6077 .unwrap_or_default();
6078 assert!(matches!(
6079 reason,
6080 "embeddings_unavailable" | "memory_db_missing"
6081 ));
6082 }
6083
6084 #[tokio::test]
6085 async fn memory_delete_uses_hidden_project_scope_by_default() {
6086 let tool = MemoryDeleteTool;
6087 let result = tool
6088 .execute(json!({
6089 "chunk_id": "chunk-123",
6090 "__session_id": "session-123",
6091 "__project_id": "workspace-abc",
6092 "__memory_db_path": "/tmp/tandem-memory-delete-test.sqlite"
6093 }))
6094 .await
6095 .expect("memory_delete should return ToolResult");
6096 assert_eq!(result.metadata["tier"], json!("project"));
6097 assert_eq!(result.metadata["project_id"], json!("workspace-abc"));
6098 assert!(matches!(
6099 result
6100 .metadata
6101 .get("reason")
6102 .and_then(|v| v.as_str())
6103 .unwrap_or_default(),
6104 "not_found"
6105 ));
6106 }
6107
6108 #[test]
6109 fn translate_windows_ls_with_all_flag() {
6110 let translated = translate_windows_shell_command("ls -la").expect("translation");
6111 assert!(translated.contains("Get-ChildItem"));
6112 assert!(translated.contains("-Force"));
6113 }
6114
6115 #[test]
6116 fn translate_windows_find_name_pattern() {
6117 let translated =
6118 translate_windows_shell_command("find . -type f -name \"*.rs\"").expect("translation");
6119 assert!(translated.contains("Get-ChildItem"));
6120 assert!(translated.contains("-Recurse"));
6121 assert!(translated.contains("-Filter"));
6122 }
6123
6124 #[test]
6125 fn windows_guardrail_blocks_untranslatable_unix_command() {
6126 assert_eq!(
6127 windows_guardrail_reason("sed -n '1,5p' README.md"),
6128 Some("unix_command_untranslatable")
6129 );
6130 }
6131
6132 #[test]
6133 fn path_policy_rejects_tool_markup_and_globs() {
6134 assert!(resolve_tool_path(
6135 "<tool_call><function=glob><parameter=pattern>**/*</parameter></function></tool_call>",
6136 &json!({})
6137 )
6138 .is_none());
6139 assert!(resolve_tool_path("**/*", &json!({})).is_none());
6140 assert!(resolve_tool_path("/", &json!({})).is_none());
6141 assert!(resolve_tool_path("C:\\", &json!({})).is_none());
6142 }
6143
6144 #[tokio::test]
6145 async fn glob_allows_tandem_artifact_paths() {
6146 let root =
6147 std::env::temp_dir().join(format!("tandem-glob-artifacts-{}", uuid_like(now_ms_u64())));
6148 let artifacts_dir = root.join(".tandem").join("artifacts");
6149 std::fs::create_dir_all(&artifacts_dir).expect("create artifacts dir");
6150 let artifact = artifacts_dir.join("report.json");
6151 std::fs::write(&artifact, "{\"ok\":true}").expect("write artifact");
6152
6153 let tool = GlobTool;
6154 let result = tool
6155 .execute(json!({
6156 "pattern": ".tandem/artifacts/*.json",
6157 "__workspace_root": root.to_string_lossy().to_string(),
6158 "__effective_cwd": root.to_string_lossy().to_string(),
6159 }))
6160 .await
6161 .expect("glob result");
6162
6163 assert!(
6164 result.output.contains(".tandem/artifacts/report.json"),
6165 "expected artifact path in glob output, got: {}",
6166 result.output
6167 );
6168 }
6169
6170 #[tokio::test]
6171 async fn glob_still_hides_non_artifact_tandem_paths() {
6172 let root =
6173 std::env::temp_dir().join(format!("tandem-glob-hidden-{}", uuid_like(now_ms_u64())));
6174 let tandem_dir = root.join(".tandem");
6175 let artifacts_dir = tandem_dir.join("artifacts");
6176 std::fs::create_dir_all(&artifacts_dir).expect("create tandem dirs");
6177 std::fs::write(tandem_dir.join("secrets.json"), "{\"hidden\":true}")
6178 .expect("write hidden file");
6179
6180 let tool = GlobTool;
6181 let result = tool
6182 .execute(json!({
6183 "pattern": ".tandem/*.json",
6184 "__workspace_root": root.to_string_lossy().to_string(),
6185 "__effective_cwd": root.to_string_lossy().to_string(),
6186 }))
6187 .await
6188 .expect("glob result");
6189
6190 assert!(
6191 result.output.trim().is_empty(),
6192 "expected non-artifact tandem paths to stay hidden, got: {}",
6193 result.output
6194 );
6195 }
6196
6197 #[test]
6198 fn normalize_recursive_wildcard_pattern_fixes_common_invalid_forms() {
6199 assert_eq!(
6200 normalize_recursive_wildcard_pattern("docs/**.md").as_deref(),
6201 Some("docs/**/*.md")
6202 );
6203 assert_eq!(
6204 normalize_recursive_wildcard_pattern("src/**README*").as_deref(),
6205 Some("src/**/README*")
6206 );
6207 assert_eq!(
6208 normalize_recursive_wildcard_pattern("**.{md,mdx,txt}").as_deref(),
6209 Some("**/*.{md,mdx,txt}")
6210 );
6211 assert_eq!(normalize_recursive_wildcard_pattern("docs/**/*.md"), None);
6212 }
6213
6214 #[tokio::test]
6215 async fn glob_recovers_from_invalid_recursive_wildcard_syntax() {
6216 let root =
6217 std::env::temp_dir().join(format!("tandem-glob-recover-{}", uuid_like(now_ms_u64())));
6218 let docs_dir = root.join("docs").join("guides");
6219 std::fs::create_dir_all(&docs_dir).expect("create docs dir");
6220 let guide = docs_dir.join("intro.md");
6221 std::fs::write(&guide, "# intro").expect("write guide");
6222
6223 let tool = GlobTool;
6224 let result = tool
6225 .execute(json!({
6226 "pattern": "docs/**.md",
6227 "__workspace_root": root.to_string_lossy().to_string(),
6228 "__effective_cwd": root.to_string_lossy().to_string(),
6229 }))
6230 .await
6231 .expect("glob result");
6232
6233 assert!(
6234 result.output.contains("docs/guides/intro.md"),
6235 "expected recovered glob output, got: {}",
6236 result.output
6237 );
6238 assert_eq!(
6239 result.metadata["effective_pattern"],
6240 json!(format!("{}/docs/**/*.md", root.to_string_lossy()))
6241 );
6242 }
6243
6244 #[cfg(windows)]
6245 #[test]
6246 fn path_policy_allows_windows_verbatim_paths_within_workspace() {
6247 let args = json!({
6248 "__workspace_root": r"C:\tandem-examples",
6249 "__effective_cwd": r"C:\tandem-examples\docs"
6250 });
6251 assert!(resolve_tool_path(r"\\?\C:\tandem-examples\docs\index.html", &args).is_some());
6252 }
6253
6254 #[cfg(not(windows))]
6255 #[test]
6256 fn path_policy_allows_absolute_linux_paths_within_workspace() {
6257 let args = json!({
6258 "__workspace_root": "/tmp/tandem-examples",
6259 "__effective_cwd": "/tmp/tandem-examples/docs"
6260 });
6261 assert!(resolve_tool_path("/tmp/tandem-examples/docs/index.html", &args).is_some());
6262 assert!(resolve_tool_path("/etc/passwd", &args).is_none());
6263 }
6264
6265 #[test]
6266 fn read_fallback_resolves_unique_suffix_filename() {
6267 let root =
6268 std::env::temp_dir().join(format!("tandem-read-fallback-{}", uuid_like(now_ms_u64())));
6269 std::fs::create_dir_all(&root).expect("create root");
6270 let target = root.join("T1011U kitöltési útmutató.pdf");
6271 std::fs::write(&target, b"stub").expect("write test file");
6272
6273 let args = json!({
6274 "__workspace_root": root.to_string_lossy().to_string(),
6275 "__effective_cwd": root.to_string_lossy().to_string()
6276 });
6277 let resolved = resolve_read_path_fallback("útmutató.pdf", &args)
6278 .expect("expected unique suffix match");
6279 assert_eq!(resolved, target);
6280
6281 let _ = std::fs::remove_dir_all(&root);
6282 }
6283
6284 #[tokio::test]
6285 async fn write_tool_rejects_empty_content_by_default() {
6286 let tool = WriteTool;
6287 let result = tool
6288 .execute(json!({
6289 "path":"target/write_guard_test.txt",
6290 "content":""
6291 }))
6292 .await
6293 .expect("write tool should return ToolResult");
6294 assert!(result.output.contains("non-empty `content`"));
6295 assert_eq!(result.metadata["reason"], json!("empty_content"));
6296 assert!(!Path::new("target/write_guard_test.txt").exists());
6297 }
6298
6299 #[tokio::test]
6300 async fn registry_resolves_default_api_namespaced_tool() {
6301 let registry = ToolRegistry::new();
6302 let result = registry
6303 .execute("default_api:read", json!({"path":"Cargo.toml"}))
6304 .await
6305 .expect("registry execute should return ToolResult");
6306 assert!(!result.output.starts_with("Unknown tool:"));
6307 }
6308
6309 #[tokio::test]
6310 async fn batch_resolves_default_api_namespaced_tool() {
6311 let tool = BatchTool;
6312 let result = tool
6313 .execute(json!({
6314 "tool_calls":[
6315 {"tool":"default_api:read","args":{"path":"Cargo.toml"}}
6316 ]
6317 }))
6318 .await
6319 .expect("batch should return ToolResult");
6320 assert!(!result.output.contains("Unknown tool: default_api:read"));
6321 }
6322
6323 #[tokio::test]
6324 async fn batch_prefers_name_when_tool_is_default_api_wrapper() {
6325 let tool = BatchTool;
6326 let result = tool
6327 .execute(json!({
6328 "tool_calls":[
6329 {"tool":"default_api","name":"read","args":{"path":"Cargo.toml"}}
6330 ]
6331 }))
6332 .await
6333 .expect("batch should return ToolResult");
6334 assert!(!result.output.contains("Unknown tool: default_api"));
6335 }
6336
6337 #[tokio::test]
6338 async fn batch_resolves_nested_function_name_for_wrapper_tool() {
6339 let tool = BatchTool;
6340 let result = tool
6341 .execute(json!({
6342 "tool_calls":[
6343 {
6344 "tool":"default_api",
6345 "function":{"name":"read"},
6346 "args":{"path":"Cargo.toml"}
6347 }
6348 ]
6349 }))
6350 .await
6351 .expect("batch should return ToolResult");
6352 assert!(!result.output.contains("Unknown tool: default_api"));
6353 }
6354
6355 #[tokio::test]
6356 async fn batch_drops_wrapper_calls_without_resolvable_name() {
6357 let tool = BatchTool;
6358 let result = tool
6359 .execute(json!({
6360 "tool_calls":[
6361 {"tool":"default_api","args":{"path":"Cargo.toml"}}
6362 ]
6363 }))
6364 .await
6365 .expect("batch should return ToolResult");
6366 assert_eq!(result.metadata["count"], json!(0));
6367 }
6368
6369 #[test]
6370 fn sanitize_member_name_normalizes_agent_aliases() {
6371 assert_eq!(sanitize_member_name("A2").expect("valid"), "A2");
6372 assert_eq!(sanitize_member_name("a7").expect("valid"), "A7");
6373 assert_eq!(
6374 sanitize_member_name(" qa reviewer ").expect("valid"),
6375 "qa-reviewer"
6376 );
6377 assert!(sanitize_member_name(" ").is_err());
6378 }
6379
6380 #[tokio::test]
6381 async fn next_default_member_name_skips_existing_indices() {
6382 let root = std::env::temp_dir().join(format!(
6383 "tandem-agent-team-test-{}",
6384 uuid_like(now_ms_u64())
6385 ));
6386 let paths = AgentTeamPaths::new(root.join(".tandem"));
6387 let team_name = "alpha";
6388 fs::create_dir_all(paths.team_dir(team_name))
6389 .await
6390 .expect("create team dir");
6391 write_json_file(
6392 paths.members_file(team_name),
6393 &json!([
6394 {"name":"A1"},
6395 {"name":"A2"},
6396 {"name":"agent-x"},
6397 {"name":"A5"}
6398 ]),
6399 )
6400 .await
6401 .expect("write members");
6402
6403 let next = next_default_member_name(&paths, team_name)
6404 .await
6405 .expect("next member");
6406 assert_eq!(next, "A6");
6407
6408 let _ =
6409 fs::remove_dir_all(PathBuf::from(paths.root().parent().unwrap_or(paths.root()))).await;
6410 }
6411}
6412
6413async fn find_symbol_references(symbol: &str, root: &Path) -> String {
6414 if symbol.trim().is_empty() {
6415 return "missing symbol".to_string();
6416 }
6417 let escaped = regex::escape(symbol);
6418 let re = Regex::new(&format!(r"\b{}\b", escaped));
6419 let Ok(re) = re else {
6420 return "invalid symbol".to_string();
6421 };
6422 let mut refs = Vec::new();
6423 for entry in WalkBuilder::new(root).build().flatten() {
6424 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
6425 continue;
6426 }
6427 let path = entry.path();
6428 if let Ok(content) = fs::read_to_string(path).await {
6429 for (idx, line) in content.lines().enumerate() {
6430 if re.is_match(line) {
6431 refs.push(format!("{}:{}:{}", path.display(), idx + 1, line.trim()));
6432 if refs.len() >= 200 {
6433 return refs.join("\n");
6434 }
6435 }
6436 }
6437 }
6438 }
6439 refs.join("\n")
6440}