mermaid_cli/providers/tool/
mod.rs1pub mod apply_patch;
20pub mod ask_user_question;
21pub mod context;
22pub mod exec;
23pub mod filesystem;
24pub mod mcp;
25pub mod memory;
26pub mod path_lock;
27pub mod path_safety;
28pub mod policy_gate;
29pub mod subagent;
30pub mod tasks;
31pub mod web;
32pub mod web_client;
33pub mod workspace;
34
35use async_trait::async_trait;
36use std::collections::HashMap;
37use std::sync::Arc;
38
39use mermaid_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 unavailable: HashMap<&'static str, String>,
87 web_capabilities: Option<Arc<web::WebCapabilities>>,
92 subagent_spawner: Option<Arc<subagent::SubagentSpawner>>,
97}
98
99impl Default for ToolRegistry {
104 fn default() -> Self {
105 Self::new()
106 }
107}
108
109impl ToolRegistry {
110 #[must_use]
111 pub fn new() -> Self {
112 Self {
113 entries: HashMap::new(),
114 unavailable: HashMap::new(),
115 web_capabilities: None,
116 subagent_spawner: None,
117 }
118 }
119
120 #[must_use]
121 pub fn web_capabilities(&self) -> Option<&web::WebCapabilities> {
122 self.web_capabilities.as_deref()
123 }
124
125 #[must_use]
126 pub fn subagent_spawner(&self) -> Option<&Arc<subagent::SubagentSpawner>> {
127 self.subagent_spawner.as_ref()
128 }
129
130 pub fn register(&mut self, tool: Arc<dyn ToolExecutor>) {
131 self.entries.insert(tool.name(), tool);
132 }
133
134 pub fn note_unavailable(&mut self, tool: &'static str, reason: impl Into<String>) {
139 self.unavailable.insert(tool, reason.into());
140 }
141
142 #[must_use]
143 pub fn unavailable_reason(&self, name: &str) -> Option<&str> {
144 self.unavailable.get(name).map(String::as_str)
145 }
146
147 #[must_use]
153 pub fn unknown_tool_outcome(&self, tool_key: &str, called_name: &str) -> ToolOutcome {
154 self.unavailable.get(tool_key).map_or_else(
155 || ToolOutcome::error(format!("unknown tool: {called_name}"), None),
156 |reason| ToolOutcome::error(format!("{called_name} is not available: {reason}"), None),
157 )
158 }
159
160 #[must_use]
161 pub fn get(&self, name: &str) -> Option<Arc<dyn ToolExecutor>> {
162 self.entries.get(name).cloned()
163 }
164
165 #[must_use]
166 pub fn len(&self) -> usize {
167 self.entries.len()
168 }
169
170 #[must_use]
171 pub fn is_empty(&self) -> bool {
172 self.entries.is_empty()
173 }
174
175 pub fn names(&self) -> impl Iterator<Item = &'static str> + '_ {
176 self.entries.keys().copied()
177 }
178
179 #[must_use]
185 pub fn describe_all(&self) -> Vec<ToolDefinition> {
186 self.entries
187 .values()
188 .filter(|t| !t.is_internal())
189 .map(|t| t.schema())
190 .collect()
191 }
192}
193
194impl ToolRegistry {
195 pub fn build(
208 config: &mermaid_domain::Config,
209 providers: Arc<crate::providers::ProviderFactory>,
210 ) -> Arc<Self> {
211 let mut r = Self::new();
212 let web_capabilities = Arc::new(web::WebCapabilities::resolve(&config.web));
213 r.register(Arc::new(filesystem::ReadFileTool));
214 r.register(Arc::new(filesystem::WriteFileTool));
215 r.register(Arc::new(filesystem::EditFileTool));
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(tasks::TaskCreateTool));
223 r.register(Arc::new(tasks::TaskUpdateTool));
224 r.register(Arc::new(tasks::TaskListTool));
225 r.register(Arc::new(context::ContextArchiveTool));
226 r.register(Arc::new(context::CompactContextTool));
227 r.register(Arc::new(mcp::McpToolProxy));
228
229 if config.safety.network == mermaid_domain::NetworkPolicy::Allow {
235 match web_capabilities.fetch_tool() {
236 Some(tool) => r.register(Arc::new(tool)),
237 None => r.note_unavailable(
238 "web_fetch",
239 web_capabilities.fetch.absence_reason("web_fetch"),
240 ),
241 }
242 match web_capabilities.search_tool() {
243 Some(tool) => r.register(Arc::new(tool)),
244 None => r.note_unavailable(
245 "web_search",
246 web_capabilities.search.absence_reason("web_search"),
247 ),
248 }
249 } else {
250 for tool in ["web_fetch", "web_search"] {
251 r.note_unavailable(
252 tool,
253 format!(
254 "{tool} is disabled: network access is off \
255 (safety.network = \"deny\" / --no-network)"
256 ),
257 );
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 fn headless_registry() -> Arc<ToolRegistry> {
286 let cfg = mermaid_domain::Config::default();
287 let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
288 ToolRegistry::build(&cfg, providers)
289 }
290
291 #[test]
292 fn default_registry_has_builtin_tools() {
293 let r = headless_registry();
294 for name in &[
295 "read_file",
296 "write_file",
297 "edit_file",
298 "apply_patch",
299 "delete_file",
300 "create_directory",
301 "execute_command",
302 "memory",
303 ] {
304 assert!(r.get(name).is_some(), "missing: {name}");
305 }
306 assert!(r.get("not_a_tool").is_none());
307 assert!(r.len() >= 6);
308 }
309
310 #[test]
311 fn describe_all_returns_one_per_user_facing_tool() {
312 let r = headless_registry();
313 let schemas = r.describe_all();
314 let visible = r
317 .names()
318 .filter(|n| r.get(n).map(|t| !t.is_internal()).unwrap_or(false))
319 .count();
320 assert_eq!(schemas.len(), visible);
321 for schema in &schemas {
322 assert!(
323 r.get(&schema.name).is_some(),
324 "schema for unknown tool: {}",
325 schema.name
326 );
327 }
328 }
329
330 #[test]
331 fn mcp_proxy_is_registered_but_internal() {
332 let r = headless_registry();
333 let proxy = r.get("mcp_proxy").expect("mcp_proxy registered");
334 assert!(proxy.is_internal());
335 assert!(!r.describe_all().iter().any(|s| s.name == "mcp_proxy"));
336 }
337
338 #[test]
339 fn schema_name_matches_executor_name() {
340 let r = headless_registry();
341 for name in r.names() {
342 let tool = r.get(name).unwrap();
343 assert_eq!(tool.name(), tool.schema().name.as_str());
344 }
345 }
346
347 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
352
353 #[test]
354 fn build_registers_zero_config_web_tools_without_key() {
355 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
359 let prior = std::env::var("OLLAMA_API_KEY").ok();
360 unsafe {
361 std::env::remove_var("OLLAMA_API_KEY");
362 }
363 let cfg = mermaid_domain::Config::default();
364 let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
365 let r = ToolRegistry::build(&cfg, providers);
366 assert!(
367 r.get("web_fetch").is_some(),
368 "native web_fetch registers without a key"
369 );
370 assert_eq!(
371 r.get("web_search").is_some(),
372 crate::searxng::managed_backend_viability().is_ok(),
373 "auto web_search registers only when managed SearXNG is viable"
374 );
375 assert!(r.get("read_file").is_some());
376 assert!(r.get("execute_command").is_some());
377 let web = r
378 .web_capabilities()
379 .expect("config-aware registries retain the resolved web status");
380 assert_eq!(web.fetch.backend, "native");
381 assert_eq!(web.search.backend, "managed_searxng");
382 unsafe {
383 if let Some(v) = prior {
384 std::env::set_var("OLLAMA_API_KEY", v);
385 }
386 }
387 }
388
389 #[test]
390 fn build_registers_ollama_web_search_with_key() {
391 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
394 let prior = std::env::var("OLLAMA_API_KEY").ok();
395 unsafe {
396 std::env::set_var("OLLAMA_API_KEY", "test-key-build");
397 }
398 let mut cfg = mermaid_domain::Config::default();
399 cfg.web.search_backend = mermaid_domain::SearchBackend::Ollama;
400 let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
401 let r = ToolRegistry::build(&cfg, providers);
402 assert!(r.get("web_search").is_some(), "web_search registered");
403 assert!(r.get("web_fetch").is_some(), "web_fetch registered");
404 unsafe {
405 match prior {
406 Some(v) => std::env::set_var("OLLAMA_API_KEY", v),
407 None => std::env::remove_var("OLLAMA_API_KEY"),
408 }
409 }
410 }
411
412 #[test]
413 fn auto_search_never_selects_cloud_just_because_a_key_exists() {
414 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
415 let prior = std::env::var("OLLAMA_API_KEY").ok();
416 unsafe {
417 std::env::set_var("OLLAMA_API_KEY", "test-key-must-not-route");
418 }
419 let cfg = mermaid_domain::Config::default();
420 let capabilities = web::WebCapabilities::resolve(&cfg.web);
421 assert_eq!(capabilities.search.backend, "managed_searxng");
422 assert_eq!(
423 capabilities.search.available,
424 crate::searxng::managed_backend_viability().is_ok()
425 );
426 unsafe {
427 match prior {
428 Some(value) => std::env::set_var("OLLAMA_API_KEY", value),
429 None => std::env::remove_var("OLLAMA_API_KEY"),
430 }
431 }
432 }
433
434 #[test]
435 fn auto_search_fallback_engages_only_when_opted_in_with_a_key() {
436 let _guard = ENV_LOCK
441 .lock()
442 .unwrap_or_else(std::sync::PoisonError::into_inner);
443 let prior = std::env::var("OLLAMA_API_KEY").ok();
444 unsafe {
445 std::env::set_var("OLLAMA_API_KEY", "test-key-fallback");
446 }
447 let mut cfg = mermaid_domain::Config::default();
448 cfg.web.allow_ollama_search_fallback = true;
449 let capabilities = web::WebCapabilities::resolve(&cfg.web);
450 if crate::searxng::managed_backend_viability().is_ok() {
451 assert_eq!(capabilities.search.backend, "managed_searxng");
452 assert!(capabilities.search.available);
453 } else {
454 assert_eq!(capabilities.search.backend, "ollama_cloud");
455 assert!(capabilities.search.available);
456 assert_eq!(capabilities.search.egress, web::Egress::OffMachine);
457 assert!(
458 capabilities.search_tool().is_some(),
459 "the fallback must produce a registrable tool"
460 );
461 }
462 unsafe {
463 match prior {
464 Some(v) => std::env::set_var("OLLAMA_API_KEY", v),
465 None => std::env::remove_var("OLLAMA_API_KEY"),
466 }
467 }
468 }
469
470 #[test]
471 fn auto_search_fallback_without_a_key_reports_the_whole_chain() {
472 let _guard = ENV_LOCK
473 .lock()
474 .unwrap_or_else(std::sync::PoisonError::into_inner);
475 let prior = std::env::var("OLLAMA_API_KEY").ok();
476 unsafe {
477 std::env::remove_var("OLLAMA_API_KEY");
478 }
479 let mut cfg = mermaid_domain::Config::default();
480 cfg.web.allow_ollama_search_fallback = true;
481 let capabilities = web::WebCapabilities::resolve(&cfg.web);
482 if crate::searxng::managed_backend_viability().is_err() {
483 assert!(!capabilities.search.available);
484 assert_eq!(capabilities.search.backend, "ollama_cloud");
485 let reason = capabilities.search.reason.as_deref().unwrap_or_default();
486 assert!(reason.contains("managed bundle"), "{reason}");
487 assert!(reason.contains("OLLAMA_API_KEY"), "{reason}");
488 }
489 unsafe {
490 if let Some(v) = prior {
491 std::env::set_var("OLLAMA_API_KEY", v);
492 }
493 }
494 }
495
496 #[test]
497 fn build_registers_searxng_web_search_without_key() {
498 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
501 let prior = std::env::var("OLLAMA_API_KEY").ok();
502 unsafe {
503 std::env::remove_var("OLLAMA_API_KEY");
504 }
505 let mut cfg = mermaid_domain::Config::default();
506 cfg.web.search_backend = mermaid_domain::SearchBackend::Searxng;
507 let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
508 let r = ToolRegistry::build(&cfg, providers);
509 assert!(
510 r.get("web_search").is_some(),
511 "searxng web_search registers without a key"
512 );
513 assert!(
514 r.get("web_fetch").is_some(),
515 "native web_fetch still present"
516 );
517 unsafe {
518 if let Some(v) = prior {
519 std::env::set_var("OLLAMA_API_KEY", v);
520 }
521 }
522 }
523
524 #[test]
525 fn network_deny_omits_all_web_capabilities() {
526 let mut cfg = mermaid_domain::Config::default();
527 cfg.safety.network = mermaid_domain::NetworkPolicy::Deny;
528 cfg.web.search_backend = mermaid_domain::SearchBackend::Searxng;
529 let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
530 let registry = ToolRegistry::build(&cfg, providers);
531 assert!(registry.get("web_fetch").is_none());
532 assert!(registry.get("web_search").is_none());
533 assert!(registry.get("read_file").is_some());
534 for tool in ["web_fetch", "web_search"] {
537 let outcome = registry.unknown_tool_outcome(tool, tool);
538 let msg = outcome.error_message().unwrap_or_default();
539 assert!(msg.contains("safety.network"), "{tool}: {msg}");
540 }
541 assert!(registry.unavailable_reason("read_file").is_none());
544 let outcome = registry.unknown_tool_outcome("frobnicate", "frobnicate");
545 assert_eq!(
546 outcome.error_message().unwrap_or_default(),
547 "unknown tool: frobnicate"
548 );
549 }
550
551 #[test]
552 fn unavailable_search_backend_reason_reaches_the_model() {
553 let _guard = ENV_LOCK
559 .lock()
560 .unwrap_or_else(std::sync::PoisonError::into_inner);
561 let prior = std::env::var("OLLAMA_API_KEY").ok();
562 unsafe {
563 std::env::remove_var("OLLAMA_API_KEY");
564 }
565 let cfg = mermaid_domain::Config::default();
566 let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
567 let registry = ToolRegistry::build(&cfg, providers);
568 match crate::searxng::managed_backend_viability() {
569 Ok(_) => {
570 assert!(registry.get("web_search").is_some());
571 assert!(registry.unavailable_reason("web_search").is_none());
572 },
573 Err(viability_reason) => {
574 assert!(registry.get("web_search").is_none());
575 let reason = registry
576 .unavailable_reason("web_search")
577 .expect("absence reason recorded");
578 assert!(
579 reason.contains(&viability_reason),
580 "must carry the real cause: {reason}"
581 );
582 assert!(
583 reason.contains("search_backend"),
584 "must carry the remediation: {reason}"
585 );
586 },
587 }
588 unsafe {
589 if let Some(v) = prior {
590 std::env::set_var("OLLAMA_API_KEY", v);
591 }
592 }
593 }
594}