mermaid_cli/providers/tool/
mod.rs1pub mod apply_patch;
19pub mod ask_user_question;
20pub mod computer_use;
21pub mod enter_plan_mode;
22pub mod exec;
23pub mod exit_plan_mode;
24pub mod filesystem;
25pub mod mcp;
26pub mod memory;
27pub mod path_lock;
28pub mod path_safety;
29pub mod policy_gate;
30pub mod subagent;
31pub mod tasks;
32pub mod web;
33pub mod web_client;
34pub mod workspace;
35
36use async_trait::async_trait;
37use std::collections::HashMap;
38use std::sync::Arc;
39
40use crate::domain::{ToolDefinition, ToolOutcome};
41
42use super::ctx::ExecContext;
43
44#[async_trait]
48pub trait ToolExecutor: Send + Sync {
49 fn name(&self) -> &'static str;
52
53 fn schema(&self) -> ToolDefinition;
59
60 fn is_internal(&self) -> bool {
66 false
67 }
68
69 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome;
73}
74
75pub struct ToolRegistry {
79 entries: HashMap<&'static str, Arc<dyn ToolExecutor>>,
80 web_capabilities: Option<Arc<web::WebCapabilities>>,
85 subagent_spawner: Option<Arc<subagent::SubagentSpawner>>,
90}
91
92impl ToolRegistry {
93 pub fn new() -> Self {
94 Self {
95 entries: HashMap::new(),
96 web_capabilities: None,
97 subagent_spawner: None,
98 }
99 }
100
101 pub fn web_capabilities(&self) -> Option<&web::WebCapabilities> {
102 self.web_capabilities.as_deref()
103 }
104
105 pub fn subagent_spawner(&self) -> Option<&Arc<subagent::SubagentSpawner>> {
106 self.subagent_spawner.as_ref()
107 }
108
109 pub fn register(&mut self, tool: Arc<dyn ToolExecutor>) {
110 self.entries.insert(tool.name(), tool);
111 }
112
113 pub fn get(&self, name: &str) -> Option<Arc<dyn ToolExecutor>> {
114 self.entries.get(name).cloned()
115 }
116
117 pub fn len(&self) -> usize {
118 self.entries.len()
119 }
120
121 pub fn is_empty(&self) -> bool {
122 self.entries.is_empty()
123 }
124
125 pub fn names(&self) -> impl Iterator<Item = &'static str> + '_ {
126 self.entries.keys().copied()
127 }
128
129 pub fn describe_all(&self) -> Vec<ToolDefinition> {
135 self.entries
136 .values()
137 .filter(|t| !t.is_internal())
138 .map(|t| t.schema())
139 .collect()
140 }
141}
142
143impl Default for ToolRegistry {
144 fn default() -> Self {
145 let mut r = Self::new();
146 r.register(Arc::new(filesystem::ReadFileTool));
147 r.register(Arc::new(filesystem::WriteFileTool));
148 r.register(Arc::new(apply_patch::ApplyPatchTool));
149 r.register(Arc::new(filesystem::DeleteFileTool));
150 r.register(Arc::new(filesystem::CreateDirectoryTool));
151 r.register(Arc::new(exec::ExecuteCommandTool));
152 r.register(Arc::new(memory::MemoryTool));
153 r.register(Arc::new(ask_user_question::AskUserQuestionTool));
154 r.register(Arc::new(enter_plan_mode::EnterPlanModeTool));
157 r.register(Arc::new(exit_plan_mode::ExitPlanModeTool));
158 r.register(Arc::new(tasks::TaskCreateTool));
159 r.register(Arc::new(tasks::TaskUpdateTool));
160 r.register(Arc::new(tasks::TaskListTool));
161 r.register(Arc::new(mcp::McpToolProxy));
165 r
166 }
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub enum TuiMode {
176 Interactive,
177 Headless,
178}
179
180impl ToolRegistry {
181 fn register_computer_use_tools(&mut self, backend: computer_use::Backend) {
187 let driver = Arc::new(computer_use::ComputerUseDriver::new(backend));
188 self.register(Arc::new(computer_use::ScreenshotTool::new(driver.clone())));
189 if backend.supports_input_injection() {
190 self.register(Arc::new(computer_use::ClickTool::new(driver.clone())));
191 self.register(Arc::new(computer_use::TypeTextTool::new(driver.clone())));
192 self.register(Arc::new(computer_use::PressKeyTool::new(driver.clone())));
193 self.register(Arc::new(computer_use::ScrollTool::new(driver.clone())));
194 self.register(Arc::new(computer_use::MouseMoveTool::new(driver.clone())));
195 }
196 if backend.supports_window_listing() {
197 self.register(Arc::new(computer_use::ListWindowsTool::new(driver.clone())));
198 }
199 }
200
201 pub fn build(
217 config: &crate::app::Config,
218 mode: TuiMode,
219 providers: Arc<crate::providers::ProviderFactory>,
220 ) -> Arc<Self> {
221 let mut r = Self::new();
222 let web_capabilities = Arc::new(web::WebCapabilities::resolve(&config.web));
223 r.register(Arc::new(filesystem::ReadFileTool));
224 r.register(Arc::new(filesystem::WriteFileTool));
225 r.register(Arc::new(apply_patch::ApplyPatchTool));
226 r.register(Arc::new(filesystem::DeleteFileTool));
227 r.register(Arc::new(filesystem::CreateDirectoryTool));
228 r.register(Arc::new(exec::ExecuteCommandTool));
229 r.register(Arc::new(memory::MemoryTool));
230 r.register(Arc::new(ask_user_question::AskUserQuestionTool));
231 r.register(Arc::new(enter_plan_mode::EnterPlanModeTool));
232 r.register(Arc::new(exit_plan_mode::ExitPlanModeTool));
233 r.register(Arc::new(tasks::TaskCreateTool));
234 r.register(Arc::new(tasks::TaskUpdateTool));
235 r.register(Arc::new(tasks::TaskListTool));
236 r.register(Arc::new(mcp::McpToolProxy));
237
238 if config.safety.network == crate::app::NetworkPolicy::Allow {
242 if let Some(tool) = web_capabilities.fetch_tool() {
243 r.register(Arc::new(tool));
244 }
245 if let Some(tool) = web_capabilities.search_tool() {
246 r.register(Arc::new(tool));
247 }
248 }
249
250 if mode == TuiMode::Interactive {
255 let backend = computer_use::probe();
256 if backend.is_usable() {
257 r.register_computer_use_tools(backend);
258 }
259 }
260
261 let spawner = Arc::new(subagent::SubagentSpawner::new(
266 providers,
267 Arc::clone(&web_capabilities),
268 ));
269 r.register(Arc::new(subagent::SubagentTool::new(spawner.clone())));
270 r.subagent_spawner = Some(spawner);
271 r.web_capabilities = Some(web_capabilities);
272
273 Arc::new(r)
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280
281 #[test]
282 fn default_registry_has_builtin_tools() {
283 let r = ToolRegistry::default();
284 for name in &[
285 "read_file",
286 "write_file",
287 "apply_patch",
288 "delete_file",
289 "create_directory",
290 "execute_command",
291 "memory",
292 ] {
293 assert!(r.get(name).is_some(), "missing: {}", name);
294 }
295 assert!(r.get("not_a_tool").is_none());
296 assert!(r.len() >= 6);
297 }
298
299 #[test]
300 fn computer_use_registration_is_selective_per_backend() {
301 use computer_use::Backend;
302 let reg = |b: Backend| {
303 let mut r = ToolRegistry::new();
304 r.register_computer_use_tools(b);
305 r
306 };
307
308 let mac = reg(Backend::MacOS);
311 assert!(mac.get("screenshot").is_some());
312 for t in [
313 "click",
314 "type_text",
315 "press_key",
316 "scroll",
317 "mouse_move",
318 "list_windows",
319 ] {
320 assert!(mac.get(t).is_none(), "macOS must not advertise {t}");
321 }
322
323 let way = reg(Backend::Wayland);
325 assert!(way.get("click").is_some());
326 assert!(way.get("list_windows").is_none());
327
328 let x11 = reg(Backend::X11);
330 for t in [
331 "screenshot",
332 "click",
333 "type_text",
334 "press_key",
335 "scroll",
336 "mouse_move",
337 "list_windows",
338 ] {
339 assert!(x11.get(t).is_some(), "X11 missing {t}");
340 }
341 }
342
343 #[test]
344 fn describe_all_returns_one_per_user_facing_tool() {
345 let r = ToolRegistry::default();
346 let schemas = r.describe_all();
347 let visible = r
350 .names()
351 .filter(|n| r.get(n).map(|t| !t.is_internal()).unwrap_or(false))
352 .count();
353 assert_eq!(schemas.len(), visible);
354 for schema in &schemas {
355 assert!(
356 r.get(&schema.name).is_some(),
357 "schema for unknown tool: {}",
358 schema.name
359 );
360 }
361 }
362
363 #[test]
364 fn mcp_proxy_is_registered_but_internal() {
365 let r = ToolRegistry::default();
366 let proxy = r.get("mcp_proxy").expect("mcp_proxy registered");
367 assert!(proxy.is_internal());
368 assert!(!r.describe_all().iter().any(|s| s.name == "mcp_proxy"));
369 }
370
371 #[test]
372 fn schema_name_matches_executor_name() {
373 let r = ToolRegistry::default();
374 for name in r.names() {
375 let tool = r.get(name).unwrap();
376 assert_eq!(tool.name(), tool.schema().name.as_str());
377 }
378 }
379
380 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
385
386 #[test]
387 fn build_registers_zero_config_web_tools_without_key() {
388 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
392 let prior = std::env::var("OLLAMA_API_KEY").ok();
393 unsafe {
394 std::env::remove_var("OLLAMA_API_KEY");
395 }
396 let cfg = crate::app::Config::default();
397 let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
398 let r = ToolRegistry::build(&cfg, TuiMode::Headless, providers);
399 assert!(
400 r.get("web_fetch").is_some(),
401 "native web_fetch registers without a key"
402 );
403 assert_eq!(
404 r.get("web_search").is_some(),
405 crate::searxng::managed_backend_viability().is_ok(),
406 "auto web_search registers only when managed SearXNG is viable"
407 );
408 assert!(r.get("read_file").is_some());
409 assert!(r.get("execute_command").is_some());
410 let web = r
411 .web_capabilities()
412 .expect("config-aware registries retain the resolved web status");
413 assert_eq!(web.fetch.backend, "native");
414 assert_eq!(web.search.backend, "managed_searxng");
415 unsafe {
416 if let Some(v) = prior {
417 std::env::set_var("OLLAMA_API_KEY", v);
418 }
419 }
420 }
421
422 #[test]
423 fn build_registers_ollama_web_search_with_key() {
424 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
427 let prior = std::env::var("OLLAMA_API_KEY").ok();
428 unsafe {
429 std::env::set_var("OLLAMA_API_KEY", "test-key-build");
430 }
431 let mut cfg = crate::app::Config::default();
432 cfg.web.search_backend = crate::app::SearchBackend::Ollama;
433 let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
434 let r = ToolRegistry::build(&cfg, TuiMode::Interactive, providers);
435 assert!(r.get("web_search").is_some(), "web_search registered");
436 assert!(r.get("web_fetch").is_some(), "web_fetch registered");
437 unsafe {
438 match prior {
439 Some(v) => std::env::set_var("OLLAMA_API_KEY", v),
440 None => std::env::remove_var("OLLAMA_API_KEY"),
441 }
442 }
443 }
444
445 #[test]
446 fn auto_search_never_selects_cloud_just_because_a_key_exists() {
447 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
448 let prior = std::env::var("OLLAMA_API_KEY").ok();
449 unsafe {
450 std::env::set_var("OLLAMA_API_KEY", "test-key-must-not-route");
451 }
452 let cfg = crate::app::Config::default();
453 let capabilities = web::WebCapabilities::resolve(&cfg.web);
454 assert_eq!(capabilities.search.backend, "managed_searxng");
455 assert_eq!(
456 capabilities.search.available,
457 crate::searxng::managed_backend_viability().is_ok()
458 );
459 unsafe {
460 match prior {
461 Some(value) => std::env::set_var("OLLAMA_API_KEY", value),
462 None => std::env::remove_var("OLLAMA_API_KEY"),
463 }
464 }
465 }
466
467 #[test]
468 fn build_registers_searxng_web_search_without_key() {
469 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
472 let prior = std::env::var("OLLAMA_API_KEY").ok();
473 unsafe {
474 std::env::remove_var("OLLAMA_API_KEY");
475 }
476 let mut cfg = crate::app::Config::default();
477 cfg.web.search_backend = crate::app::SearchBackend::Searxng;
478 let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
479 let r = ToolRegistry::build(&cfg, TuiMode::Headless, providers);
480 assert!(
481 r.get("web_search").is_some(),
482 "searxng web_search registers without a key"
483 );
484 assert!(
485 r.get("web_fetch").is_some(),
486 "native web_fetch still present"
487 );
488 unsafe {
489 if let Some(v) = prior {
490 std::env::set_var("OLLAMA_API_KEY", v);
491 }
492 }
493 }
494
495 #[test]
496 fn network_deny_omits_all_web_capabilities() {
497 let mut cfg = crate::app::Config::default();
498 cfg.safety.network = crate::app::NetworkPolicy::Deny;
499 cfg.web.search_backend = crate::app::SearchBackend::Searxng;
500 let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
501 let registry = ToolRegistry::build(&cfg, TuiMode::Headless, providers);
502 assert!(registry.get("web_fetch").is_none());
503 assert!(registry.get("web_search").is_none());
504 assert!(registry.get("read_file").is_some());
505 }
506}