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 mermaid_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 #[must_use]
94 pub fn new() -> Self {
95 Self {
96 entries: HashMap::new(),
97 web_capabilities: None,
98 subagent_spawner: None,
99 }
100 }
101
102 #[must_use]
103 pub fn web_capabilities(&self) -> Option<&web::WebCapabilities> {
104 self.web_capabilities.as_deref()
105 }
106
107 #[must_use]
108 pub fn subagent_spawner(&self) -> Option<&Arc<subagent::SubagentSpawner>> {
109 self.subagent_spawner.as_ref()
110 }
111
112 pub fn register(&mut self, tool: Arc<dyn ToolExecutor>) {
113 self.entries.insert(tool.name(), tool);
114 }
115
116 #[must_use]
117 pub fn get(&self, name: &str) -> Option<Arc<dyn ToolExecutor>> {
118 self.entries.get(name).cloned()
119 }
120
121 #[must_use]
122 pub fn len(&self) -> usize {
123 self.entries.len()
124 }
125
126 #[must_use]
127 pub fn is_empty(&self) -> bool {
128 self.entries.is_empty()
129 }
130
131 pub fn names(&self) -> impl Iterator<Item = &'static str> + '_ {
132 self.entries.keys().copied()
133 }
134
135 #[must_use]
141 pub fn describe_all(&self) -> Vec<ToolDefinition> {
142 self.entries
143 .values()
144 .filter(|t| !t.is_internal())
145 .map(|t| t.schema())
146 .collect()
147 }
148}
149
150impl Default for ToolRegistry {
151 fn default() -> Self {
152 let mut r = Self::new();
153 r.register(Arc::new(filesystem::ReadFileTool));
154 r.register(Arc::new(filesystem::WriteFileTool));
155 r.register(Arc::new(apply_patch::ApplyPatchTool));
156 r.register(Arc::new(filesystem::DeleteFileTool));
157 r.register(Arc::new(filesystem::CreateDirectoryTool));
158 r.register(Arc::new(exec::ExecuteCommandTool));
159 r.register(Arc::new(memory::MemoryTool));
160 r.register(Arc::new(ask_user_question::AskUserQuestionTool));
161 r.register(Arc::new(enter_plan_mode::EnterPlanModeTool));
164 r.register(Arc::new(exit_plan_mode::ExitPlanModeTool));
165 r.register(Arc::new(tasks::TaskCreateTool));
166 r.register(Arc::new(tasks::TaskUpdateTool));
167 r.register(Arc::new(tasks::TaskListTool));
168 r.register(Arc::new(mcp::McpToolProxy));
172 r
173 }
174}
175
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub enum TuiMode {
183 Interactive,
184 Headless,
185}
186
187impl ToolRegistry {
188 fn register_computer_use_tools(&mut self, backend: computer_use::Backend) {
194 let driver = Arc::new(computer_use::ComputerUseDriver::new(backend));
195 self.register(Arc::new(computer_use::ScreenshotTool::new(driver.clone())));
196 if backend.supports_input_injection() {
197 self.register(Arc::new(computer_use::ClickTool::new(driver.clone())));
198 self.register(Arc::new(computer_use::TypeTextTool::new(driver.clone())));
199 self.register(Arc::new(computer_use::PressKeyTool::new(driver.clone())));
200 self.register(Arc::new(computer_use::ScrollTool::new(driver.clone())));
201 self.register(Arc::new(computer_use::MouseMoveTool::new(driver.clone())));
202 }
203 if backend.supports_window_listing() {
204 self.register(Arc::new(computer_use::ListWindowsTool::new(driver.clone())));
205 }
206 }
207
208 pub fn build(
224 config: &mermaid_domain::Config,
225 mode: TuiMode,
226 providers: Arc<crate::providers::ProviderFactory>,
227 ) -> Arc<Self> {
228 let mut r = Self::new();
229 let web_capabilities = Arc::new(web::WebCapabilities::resolve(&config.web));
230 r.register(Arc::new(filesystem::ReadFileTool));
231 r.register(Arc::new(filesystem::WriteFileTool));
232 r.register(Arc::new(apply_patch::ApplyPatchTool));
233 r.register(Arc::new(filesystem::DeleteFileTool));
234 r.register(Arc::new(filesystem::CreateDirectoryTool));
235 r.register(Arc::new(exec::ExecuteCommandTool));
236 r.register(Arc::new(memory::MemoryTool));
237 r.register(Arc::new(ask_user_question::AskUserQuestionTool));
238 r.register(Arc::new(enter_plan_mode::EnterPlanModeTool));
239 r.register(Arc::new(exit_plan_mode::ExitPlanModeTool));
240 r.register(Arc::new(tasks::TaskCreateTool));
241 r.register(Arc::new(tasks::TaskUpdateTool));
242 r.register(Arc::new(tasks::TaskListTool));
243 r.register(Arc::new(mcp::McpToolProxy));
244
245 if config.safety.network == mermaid_domain::NetworkPolicy::Allow {
249 if let Some(tool) = web_capabilities.fetch_tool() {
250 r.register(Arc::new(tool));
251 }
252 if let Some(tool) = web_capabilities.search_tool() {
253 r.register(Arc::new(tool));
254 }
255 }
256
257 if mode == TuiMode::Interactive {
262 let backend = computer_use::probe();
263 if backend.is_usable() {
264 r.register_computer_use_tools(backend);
265 }
266 }
267
268 let spawner = Arc::new(subagent::SubagentSpawner::new(
273 providers,
274 Arc::clone(&web_capabilities),
275 ));
276 r.register(Arc::new(subagent::SubagentTool::new(spawner.clone())));
277 r.subagent_spawner = Some(spawner);
278 r.web_capabilities = Some(web_capabilities);
279
280 Arc::new(r)
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 #[test]
289 fn default_registry_has_builtin_tools() {
290 let r = ToolRegistry::default();
291 for name in &[
292 "read_file",
293 "write_file",
294 "apply_patch",
295 "delete_file",
296 "create_directory",
297 "execute_command",
298 "memory",
299 ] {
300 assert!(r.get(name).is_some(), "missing: {name}");
301 }
302 assert!(r.get("not_a_tool").is_none());
303 assert!(r.len() >= 6);
304 }
305
306 #[test]
307 fn computer_use_registration_is_selective_per_backend() {
308 use computer_use::Backend;
309 let reg = |b: Backend| {
310 let mut r = ToolRegistry::new();
311 r.register_computer_use_tools(b);
312 r
313 };
314
315 let mac = reg(Backend::MacOS);
318 assert!(mac.get("screenshot").is_some());
319 for t in [
320 "click",
321 "type_text",
322 "press_key",
323 "scroll",
324 "mouse_move",
325 "list_windows",
326 ] {
327 assert!(mac.get(t).is_none(), "macOS must not advertise {t}");
328 }
329
330 let way = reg(Backend::Wayland);
332 assert!(way.get("click").is_some());
333 assert!(way.get("list_windows").is_none());
334
335 let x11 = reg(Backend::X11);
337 for t in [
338 "screenshot",
339 "click",
340 "type_text",
341 "press_key",
342 "scroll",
343 "mouse_move",
344 "list_windows",
345 ] {
346 assert!(x11.get(t).is_some(), "X11 missing {t}");
347 }
348 }
349
350 #[test]
351 fn describe_all_returns_one_per_user_facing_tool() {
352 let r = ToolRegistry::default();
353 let schemas = r.describe_all();
354 let visible = r
357 .names()
358 .filter(|n| r.get(n).map(|t| !t.is_internal()).unwrap_or(false))
359 .count();
360 assert_eq!(schemas.len(), visible);
361 for schema in &schemas {
362 assert!(
363 r.get(&schema.name).is_some(),
364 "schema for unknown tool: {}",
365 schema.name
366 );
367 }
368 }
369
370 #[test]
371 fn mcp_proxy_is_registered_but_internal() {
372 let r = ToolRegistry::default();
373 let proxy = r.get("mcp_proxy").expect("mcp_proxy registered");
374 assert!(proxy.is_internal());
375 assert!(!r.describe_all().iter().any(|s| s.name == "mcp_proxy"));
376 }
377
378 #[test]
379 fn schema_name_matches_executor_name() {
380 let r = ToolRegistry::default();
381 for name in r.names() {
382 let tool = r.get(name).unwrap();
383 assert_eq!(tool.name(), tool.schema().name.as_str());
384 }
385 }
386
387 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
392
393 #[test]
394 fn build_registers_zero_config_web_tools_without_key() {
395 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
399 let prior = std::env::var("OLLAMA_API_KEY").ok();
400 unsafe {
401 std::env::remove_var("OLLAMA_API_KEY");
402 }
403 let cfg = mermaid_domain::Config::default();
404 let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
405 let r = ToolRegistry::build(&cfg, TuiMode::Headless, providers);
406 assert!(
407 r.get("web_fetch").is_some(),
408 "native web_fetch registers without a key"
409 );
410 assert_eq!(
411 r.get("web_search").is_some(),
412 crate::searxng::managed_backend_viability().is_ok(),
413 "auto web_search registers only when managed SearXNG is viable"
414 );
415 assert!(r.get("read_file").is_some());
416 assert!(r.get("execute_command").is_some());
417 let web = r
418 .web_capabilities()
419 .expect("config-aware registries retain the resolved web status");
420 assert_eq!(web.fetch.backend, "native");
421 assert_eq!(web.search.backend, "managed_searxng");
422 unsafe {
423 if let Some(v) = prior {
424 std::env::set_var("OLLAMA_API_KEY", v);
425 }
426 }
427 }
428
429 #[test]
430 fn build_registers_ollama_web_search_with_key() {
431 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
434 let prior = std::env::var("OLLAMA_API_KEY").ok();
435 unsafe {
436 std::env::set_var("OLLAMA_API_KEY", "test-key-build");
437 }
438 let mut cfg = mermaid_domain::Config::default();
439 cfg.web.search_backend = mermaid_domain::SearchBackend::Ollama;
440 let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
441 let r = ToolRegistry::build(&cfg, TuiMode::Interactive, providers);
442 assert!(r.get("web_search").is_some(), "web_search registered");
443 assert!(r.get("web_fetch").is_some(), "web_fetch registered");
444 unsafe {
445 match prior {
446 Some(v) => std::env::set_var("OLLAMA_API_KEY", v),
447 None => std::env::remove_var("OLLAMA_API_KEY"),
448 }
449 }
450 }
451
452 #[test]
453 fn auto_search_never_selects_cloud_just_because_a_key_exists() {
454 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
455 let prior = std::env::var("OLLAMA_API_KEY").ok();
456 unsafe {
457 std::env::set_var("OLLAMA_API_KEY", "test-key-must-not-route");
458 }
459 let cfg = mermaid_domain::Config::default();
460 let capabilities = web::WebCapabilities::resolve(&cfg.web);
461 assert_eq!(capabilities.search.backend, "managed_searxng");
462 assert_eq!(
463 capabilities.search.available,
464 crate::searxng::managed_backend_viability().is_ok()
465 );
466 unsafe {
467 match prior {
468 Some(value) => std::env::set_var("OLLAMA_API_KEY", value),
469 None => std::env::remove_var("OLLAMA_API_KEY"),
470 }
471 }
472 }
473
474 #[test]
475 fn build_registers_searxng_web_search_without_key() {
476 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
479 let prior = std::env::var("OLLAMA_API_KEY").ok();
480 unsafe {
481 std::env::remove_var("OLLAMA_API_KEY");
482 }
483 let mut cfg = mermaid_domain::Config::default();
484 cfg.web.search_backend = mermaid_domain::SearchBackend::Searxng;
485 let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
486 let r = ToolRegistry::build(&cfg, TuiMode::Headless, providers);
487 assert!(
488 r.get("web_search").is_some(),
489 "searxng web_search registers without a key"
490 );
491 assert!(
492 r.get("web_fetch").is_some(),
493 "native web_fetch still present"
494 );
495 unsafe {
496 if let Some(v) = prior {
497 std::env::set_var("OLLAMA_API_KEY", v);
498 }
499 }
500 }
501
502 #[test]
503 fn network_deny_omits_all_web_capabilities() {
504 let mut cfg = mermaid_domain::Config::default();
505 cfg.safety.network = mermaid_domain::NetworkPolicy::Deny;
506 cfg.web.search_backend = mermaid_domain::SearchBackend::Searxng;
507 let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
508 let registry = ToolRegistry::build(&cfg, TuiMode::Headless, providers);
509 assert!(registry.get("web_fetch").is_none());
510 assert!(registry.get("web_search").is_none());
511 assert!(registry.get("read_file").is_some());
512 }
513}