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 subagent_spawner: Option<Arc<subagent::SubagentSpawner>>,
84}
85
86impl ToolRegistry {
87 pub fn new() -> Self {
88 Self {
89 entries: HashMap::new(),
90 subagent_spawner: None,
91 }
92 }
93
94 pub fn subagent_spawner(&self) -> Option<&Arc<subagent::SubagentSpawner>> {
95 self.subagent_spawner.as_ref()
96 }
97
98 pub fn register(&mut self, tool: Arc<dyn ToolExecutor>) {
99 self.entries.insert(tool.name(), tool);
100 }
101
102 pub fn get(&self, name: &str) -> Option<Arc<dyn ToolExecutor>> {
103 self.entries.get(name).cloned()
104 }
105
106 pub fn len(&self) -> usize {
107 self.entries.len()
108 }
109
110 pub fn is_empty(&self) -> bool {
111 self.entries.is_empty()
112 }
113
114 pub fn names(&self) -> impl Iterator<Item = &'static str> + '_ {
115 self.entries.keys().copied()
116 }
117
118 pub fn describe_all(&self) -> Vec<ToolDefinition> {
124 self.entries
125 .values()
126 .filter(|t| !t.is_internal())
127 .map(|t| t.schema())
128 .collect()
129 }
130}
131
132impl Default for ToolRegistry {
133 fn default() -> Self {
134 let mut r = Self::new();
135 r.register(Arc::new(filesystem::ReadFileTool));
136 r.register(Arc::new(filesystem::WriteFileTool));
137 r.register(Arc::new(apply_patch::ApplyPatchTool));
138 r.register(Arc::new(filesystem::DeleteFileTool));
139 r.register(Arc::new(filesystem::CreateDirectoryTool));
140 r.register(Arc::new(exec::ExecuteCommandTool));
141 r.register(Arc::new(memory::MemoryTool));
142 r.register(Arc::new(ask_user_question::AskUserQuestionTool));
143 r.register(Arc::new(enter_plan_mode::EnterPlanModeTool));
146 r.register(Arc::new(exit_plan_mode::ExitPlanModeTool));
147 r.register(Arc::new(tasks::TaskCreateTool));
148 r.register(Arc::new(tasks::TaskUpdateTool));
149 r.register(Arc::new(tasks::TaskListTool));
150 r.register(Arc::new(mcp::McpToolProxy));
154 r
155 }
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum TuiMode {
165 Interactive,
166 Headless,
167}
168
169impl ToolRegistry {
170 fn register_computer_use_tools(&mut self, backend: computer_use::Backend) {
176 let driver = Arc::new(computer_use::ComputerUseDriver::new(backend));
177 self.register(Arc::new(computer_use::ScreenshotTool::new(driver.clone())));
178 if backend.supports_input_injection() {
179 self.register(Arc::new(computer_use::ClickTool::new(driver.clone())));
180 self.register(Arc::new(computer_use::TypeTextTool::new(driver.clone())));
181 self.register(Arc::new(computer_use::PressKeyTool::new(driver.clone())));
182 self.register(Arc::new(computer_use::ScrollTool::new(driver.clone())));
183 self.register(Arc::new(computer_use::MouseMoveTool::new(driver.clone())));
184 }
185 if backend.supports_window_listing() {
186 self.register(Arc::new(computer_use::ListWindowsTool::new(driver.clone())));
187 }
188 }
189
190 pub fn build(
209 config: &crate::app::Config,
210 mode: TuiMode,
211 providers: Arc<crate::providers::ProviderFactory>,
212 ) -> Arc<Self> {
213 let mut r = Self::new();
214 r.register(Arc::new(filesystem::ReadFileTool));
215 r.register(Arc::new(filesystem::WriteFileTool));
216 r.register(Arc::new(apply_patch::ApplyPatchTool));
217 r.register(Arc::new(filesystem::DeleteFileTool));
218 r.register(Arc::new(filesystem::CreateDirectoryTool));
219 r.register(Arc::new(exec::ExecuteCommandTool));
220 r.register(Arc::new(memory::MemoryTool));
221 r.register(Arc::new(ask_user_question::AskUserQuestionTool));
222 r.register(Arc::new(enter_plan_mode::EnterPlanModeTool));
223 r.register(Arc::new(exit_plan_mode::ExitPlanModeTool));
224 r.register(Arc::new(tasks::TaskCreateTool));
225 r.register(Arc::new(tasks::TaskUpdateTool));
226 r.register(Arc::new(tasks::TaskListTool));
227 r.register(Arc::new(mcp::McpToolProxy));
228
229 if let Some(tool) = web::web_fetch_tool(&config.web) {
230 r.register(Arc::new(tool));
231 }
232 if let Some(tool) = web::web_search_tool(&config.web) {
233 r.register(Arc::new(tool));
234 }
235
236 if mode == TuiMode::Interactive {
241 let backend = computer_use::probe();
242 if backend.is_usable() {
243 r.register_computer_use_tools(backend);
244 }
245 }
246
247 let spawner = Arc::new(subagent::SubagentSpawner::new(providers));
252 r.register(Arc::new(subagent::SubagentTool::new(spawner.clone())));
253 r.subagent_spawner = Some(spawner);
254
255 Arc::new(r)
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 #[test]
264 fn default_registry_has_builtin_tools() {
265 let r = ToolRegistry::default();
266 for name in &[
267 "read_file",
268 "write_file",
269 "apply_patch",
270 "delete_file",
271 "create_directory",
272 "execute_command",
273 "memory",
274 ] {
275 assert!(r.get(name).is_some(), "missing: {}", name);
276 }
277 assert!(r.get("not_a_tool").is_none());
278 assert!(r.len() >= 6);
279 }
280
281 #[test]
282 fn computer_use_registration_is_selective_per_backend() {
283 use computer_use::Backend;
284 let reg = |b: Backend| {
285 let mut r = ToolRegistry::new();
286 r.register_computer_use_tools(b);
287 r
288 };
289
290 let mac = reg(Backend::MacOS);
293 assert!(mac.get("screenshot").is_some());
294 for t in [
295 "click",
296 "type_text",
297 "press_key",
298 "scroll",
299 "mouse_move",
300 "list_windows",
301 ] {
302 assert!(mac.get(t).is_none(), "macOS must not advertise {t}");
303 }
304
305 let way = reg(Backend::Wayland);
307 assert!(way.get("click").is_some());
308 assert!(way.get("list_windows").is_none());
309
310 let x11 = reg(Backend::X11);
312 for t in [
313 "screenshot",
314 "click",
315 "type_text",
316 "press_key",
317 "scroll",
318 "mouse_move",
319 "list_windows",
320 ] {
321 assert!(x11.get(t).is_some(), "X11 missing {t}");
322 }
323 }
324
325 #[test]
326 fn describe_all_returns_one_per_user_facing_tool() {
327 let r = ToolRegistry::default();
328 let schemas = r.describe_all();
329 let visible = r
332 .names()
333 .filter(|n| r.get(n).map(|t| !t.is_internal()).unwrap_or(false))
334 .count();
335 assert_eq!(schemas.len(), visible);
336 for schema in &schemas {
337 assert!(
338 r.get(&schema.name).is_some(),
339 "schema for unknown tool: {}",
340 schema.name
341 );
342 }
343 }
344
345 #[test]
346 fn mcp_proxy_is_registered_but_internal() {
347 let r = ToolRegistry::default();
348 let proxy = r.get("mcp_proxy").expect("mcp_proxy registered");
349 assert!(proxy.is_internal());
350 assert!(!r.describe_all().iter().any(|s| s.name == "mcp_proxy"));
351 }
352
353 #[test]
354 fn schema_name_matches_executor_name() {
355 let r = ToolRegistry::default();
356 for name in r.names() {
357 let tool = r.get(name).unwrap();
358 assert_eq!(tool.name(), tool.schema().name.as_str());
359 }
360 }
361
362 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
367
368 #[test]
369 fn build_registers_zero_config_web_tools_without_key() {
370 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
374 let prior = std::env::var("OLLAMA_API_KEY").ok();
375 unsafe {
376 std::env::remove_var("OLLAMA_API_KEY");
377 }
378 let cfg = crate::app::Config::default();
379 let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
380 let r = ToolRegistry::build(&cfg, TuiMode::Headless, providers);
381 assert!(
382 r.get("web_fetch").is_some(),
383 "native web_fetch registers without a key"
384 );
385 assert!(
386 r.get("web_search").is_some(),
387 "auto web_search (managed SearXNG) registers without a key"
388 );
389 assert!(r.get("read_file").is_some());
390 assert!(r.get("execute_command").is_some());
391 unsafe {
392 if let Some(v) = prior {
393 std::env::set_var("OLLAMA_API_KEY", v);
394 }
395 }
396 }
397
398 #[test]
399 fn build_registers_ollama_web_search_with_key() {
400 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
402 let prior = std::env::var("OLLAMA_API_KEY").ok();
403 unsafe {
404 std::env::set_var("OLLAMA_API_KEY", "test-key-build");
405 }
406 let cfg = crate::app::Config::default();
407 let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
408 let r = ToolRegistry::build(&cfg, TuiMode::Interactive, providers);
409 assert!(r.get("web_search").is_some(), "web_search registered");
410 assert!(r.get("web_fetch").is_some(), "web_fetch registered");
411 unsafe {
412 match prior {
413 Some(v) => std::env::set_var("OLLAMA_API_KEY", v),
414 None => std::env::remove_var("OLLAMA_API_KEY"),
415 }
416 }
417 }
418
419 #[test]
420 fn build_registers_searxng_web_search_without_key() {
421 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
424 let prior = std::env::var("OLLAMA_API_KEY").ok();
425 unsafe {
426 std::env::remove_var("OLLAMA_API_KEY");
427 }
428 let mut cfg = crate::app::Config::default();
429 cfg.web.search_backend = crate::app::SearchBackend::Searxng;
430 let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
431 let r = ToolRegistry::build(&cfg, TuiMode::Headless, providers);
432 assert!(
433 r.get("web_search").is_some(),
434 "searxng web_search registers without a key"
435 );
436 assert!(
437 r.get("web_fetch").is_some(),
438 "native web_fetch still present"
439 );
440 unsafe {
441 if let Some(v) = prior {
442 std::env::set_var("OLLAMA_API_KEY", v);
443 }
444 }
445 }
446}