Skip to main content

mermaid_cli/providers/tool/
mod.rs

1//! Tool executors — one type per tool the model can call.
2//!
3//! The trait is small: `execute(args, ctx) -> ToolOutcome` for
4//! dispatch, plus `schema() -> ToolDefinition` for advertising the
5//! tool to the model. Everything else (cancellation, progress,
6//! identity, workdir) rides inside `ExecContext`.
7//!
8//! Adding a tool:
9//!   1. New file under `src/providers/tool/`.
10//!   2. Impl `ToolExecutor` for a unit struct — both `execute` and
11//!      `schema`.
12//!   3. Register it in `ToolRegistry::default()`.
13//!
14//! Because `schema()` lives on the same trait as `execute()`, the
15//! name + JSON schema the model sees cannot drift from the handler
16//! that runs when the model calls it. Single source of truth.
17
18pub 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/// Implemented by every tool that the model can call. All tools are
45/// `Send + Sync` — they run across tokio `select!` branches inside
46/// the effect runner.
47#[async_trait]
48pub trait ToolExecutor: Send + Sync {
49    /// Canonical name the model uses to call this tool. Matches
50    /// `schema().name` exactly.
51    fn name(&self) -> &'static str;
52
53    /// JSON-schema description the model sees in the outgoing
54    /// request. Adapters translate this into provider-native shape
55    /// (Anthropic's `type: "custom"`, Gemini's `function_declarations`,
56    /// OpenAI's flat `tools`, Ollama's function calling). The same
57    /// `ToolDefinition` feeds all four.
58    fn schema(&self) -> ToolDefinition;
59
60    /// True for tools that exist for internal dispatch only and
61    /// should NOT be advertised to the model (e.g. the MCP proxy
62    /// router, which fronts every `mcp__server__tool` call — the
63    /// individual MCP tools are advertised separately from
64    /// `state.mcp.servers`). Default `false`.
65    fn is_internal(&self) -> bool {
66        false
67    }
68
69    /// Run the tool. The returned `ToolOutcome` is passed verbatim
70    /// into `Msg::ToolFinished` — there's no error-to-outcome
71    /// conversion happening outside this function.
72    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome;
73}
74
75/// Registry of dispatchable tools. Single source of truth for what
76/// the model sees AND what handles a call when the model issues it.
77/// Built once at startup; read-only after that.
78pub struct ToolRegistry {
79    entries: HashMap<&'static str, Arc<dyn ToolExecutor>>,
80    /// Startup-resolved web routing/viability shared by the parent registry,
81    /// its provider-facing definitions, UI diagnostics, and every child
82    /// registry. Keeping the backend clients here prevents credentials or
83    /// environment changes from silently re-resolving a different route.
84    web_capabilities: Option<Arc<web::WebCapabilities>>,
85    /// Direct handle to the subagent spawner (also reachable through the
86    /// `agent` tool entry, but `dyn ToolExecutor` can't be downcast). The
87    /// effect layer uses it to service `Cmd::KillBackgroundAgent`. `None`
88    /// in registries built without a spawner (child registries, tests).
89    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    /// Emit every user-facing tool's schema, for inclusion in an
130    /// outgoing `ChatRequest.tools`. Effect runner calls this before
131    /// dispatching `Cmd::CallModel` so the model always sees the
132    /// same list the runner can dispatch. Internal routers (the MCP
133    /// proxy) are filtered out.
134    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        // Plan-mode tools are internal (never in describe_all): the reducer
155        // advertises each definition only in the mode where it applies.
156        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        // MCP proxy is the dispatcher for every mcp__server__tool
162        // call; it's internal (not advertised) but MUST be registered
163        // so runtime lookups succeed.
164        r.register(Arc::new(mcp::McpToolProxy));
165        r
166    }
167}
168
169/// Whether the host mermaid process is running interactively (TUI)
170/// or headlessly (one-shot `mermaid run <prompt>` / CI). Controls
171/// which tools get registered: headless mode never advertises
172/// GUI / computer-use tools even when a display probes alive, because
173/// a CI job has no user to watch the screenshot.
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub enum TuiMode {
176    Interactive,
177    Headless,
178}
179
180impl ToolRegistry {
181    /// Register the computer-use tools the given backend can actually drive.
182    /// Screenshot works on every usable backend; the five input tools need
183    /// pointer/keyboard injection (X11/Wayland); `list_windows` is X11-only.
184    /// Advertising a tool the backend can't drive means the model is told it
185    /// has a capability that `bail!`s at call time (#35).
186    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    /// Config-aware factory. Always registers filesystem + exec +
202    /// the MCP proxy + the subagent tool. Conditionally registers:
203    ///
204    ///   - Viable `web_fetch` and `web_search` capabilities resolved once by
205    ///     `web::WebCapabilities`. Global network denial omits both.
206    ///   - The computer-use tools the detected backend can drive (see
207    ///     `register_computer_use_tools`) iff `mode == Interactive` AND
208    ///     `computer_use::probe()` returns a usable backend.
209    ///
210    /// `providers` is the shared `ProviderFactory` that the effect
211    /// runner also holds; the `SubagentSpawner` needs it so child
212    /// reducer loops hit the same provider cache.
213    ///
214    /// Returns `Arc<Self>` so the effect runner can share a handle
215    /// across turns without cloning the underlying HashMap.
216    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        // `safety.network = "deny"` is a global egress kill-switch, not only
239        // a shell sandbox flag. Omit web capabilities entirely so adapters and
240        // subagents cannot advertise or execute them.
241        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        // Computer-use tools only register when (a) the process runs
251        // interactively (Headless CI has no user to watch a screenshot)
252        // AND (b) a display backend passes the startup probe. Failed
253        // probe → tools aren't advertised → model can't call them.
254        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        // Subagents: always register. Depth + breadth caps live on
262        // `SubagentSpawner`; the tool itself is harmless when nobody
263        // calls it. Headless runs do register the agent — a CI prompt
264        // may still delegate to subagents for batched work.
265        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        // macOS: only screenshot — the input verbs + list_windows bail at
309        // runtime, so advertising them just wastes the model's turns (#35).
310        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        // Wayland: input tools, but no list_windows (no portable enumeration).
324        let way = reg(Backend::Wayland);
325        assert!(way.get("click").is_some());
326        assert!(way.get("list_windows").is_none());
327
328        // X11: all seven.
329        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        // mcp_proxy is registered but internal — filtered out of
348        // describe_all. So len() includes it but schemas don't.
349        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    /// Serialization guard for tests that mutate the `OLLAMA_API_KEY`
381    /// env var. Cargo's default test harness runs tests in parallel
382    /// threads inside one process; without this mutex two env-touching
383    /// tests would race and occasionally flip each other's expectations.
384    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
385
386    #[test]
387    fn build_registers_zero_config_web_tools_without_key() {
388        // Both web tools register with no OLLAMA_API_KEY: web_fetch is native,
389        // and web_search defaults to `auto`, which falls back to a managed local
390        // SearXNG (the process starts lazily at call time, not here).
391        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        // Cloud routing is explicit: a key plus an explicit Ollama backend
425        // registers search without changing the native fetch default.
426        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        // The SearXNG search backend registers regardless of OLLAMA_API_KEY —
470        // reachability is a call-time concern, not a registration one.
471        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}