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