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 mermaid_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    #[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    /// Emit every user-facing tool's schema, for inclusion in an
136    /// outgoing `ChatRequest.tools`. Effect runner calls this before
137    /// dispatching `Cmd::CallModel` so the model always sees the
138    /// same list the runner can dispatch. Internal routers (the MCP
139    /// proxy) are filtered out.
140    #[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        // Plan-mode tools are internal (never in describe_all): the reducer
162        // advertises each definition only in the mode where it applies.
163        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        // MCP proxy is the dispatcher for every mcp__server__tool
169        // call; it's internal (not advertised) but MUST be registered
170        // so runtime lookups succeed.
171        r.register(Arc::new(mcp::McpToolProxy));
172        r
173    }
174}
175
176/// Whether the host mermaid process is running interactively (TUI)
177/// or headlessly (one-shot `mermaid run <prompt>` / CI). Controls
178/// which tools get registered: headless mode never advertises
179/// GUI / computer-use tools even when a display probes alive, because
180/// a CI job has no user to watch the screenshot.
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub enum TuiMode {
183    Interactive,
184    Headless,
185}
186
187impl ToolRegistry {
188    /// Register the computer-use tools the given backend can actually drive.
189    /// Screenshot works on every usable backend; the five input tools need
190    /// pointer/keyboard injection (X11/Wayland); `list_windows` is X11-only.
191    /// Advertising a tool the backend can't drive means the model is told it
192    /// has a capability that `bail!`s at call time (#35).
193    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    /// Config-aware factory. Always registers filesystem + exec +
209    /// the MCP proxy + the subagent tool. Conditionally registers:
210    ///
211    ///   - Viable `web_fetch` and `web_search` capabilities resolved once by
212    ///     `web::WebCapabilities`. Global network denial omits both.
213    ///   - The computer-use tools the detected backend can drive (see
214    ///     `register_computer_use_tools`) iff `mode == Interactive` AND
215    ///     `computer_use::probe()` returns a usable backend.
216    ///
217    /// `providers` is the shared `ProviderFactory` that the effect
218    /// runner also holds; the `SubagentSpawner` needs it so child
219    /// reducer loops hit the same provider cache.
220    ///
221    /// Returns `Arc<Self>` so the effect runner can share a handle
222    /// across turns without cloning the underlying `HashMap`.
223    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        // `safety.network = "deny"` is a global egress kill-switch, not only
246        // a shell sandbox flag. Omit web capabilities entirely so adapters and
247        // subagents cannot advertise or execute them.
248        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        // Computer-use tools only register when (a) the process runs
258        // interactively (Headless CI has no user to watch a screenshot)
259        // AND (b) a display backend passes the startup probe. Failed
260        // probe → tools aren't advertised → model can't call them.
261        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        // Subagents: always register. Depth + breadth caps live on
269        // `SubagentSpawner`; the tool itself is harmless when nobody
270        // calls it. Headless runs do register the agent — a CI prompt
271        // may still delegate to subagents for batched work.
272        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        // macOS: only screenshot — the input verbs + list_windows bail at
316        // runtime, so advertising them just wastes the model's turns (#35).
317        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        // Wayland: input tools, but no list_windows (no portable enumeration).
331        let way = reg(Backend::Wayland);
332        assert!(way.get("click").is_some());
333        assert!(way.get("list_windows").is_none());
334
335        // X11: all seven.
336        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        // mcp_proxy is registered but internal — filtered out of
355        // describe_all. So len() includes it but schemas don't.
356        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    /// Serialization guard for tests that mutate the `OLLAMA_API_KEY`
388    /// env var. Cargo's default test harness runs tests in parallel
389    /// threads inside one process; without this mutex two env-touching
390    /// tests would race and occasionally flip each other's expectations.
391    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
392
393    #[test]
394    fn build_registers_zero_config_web_tools_without_key() {
395        // Both web tools register with no OLLAMA_API_KEY: web_fetch is native,
396        // and web_search defaults to `auto`, which falls back to a managed local
397        // SearXNG (the process starts lazily at call time, not here).
398        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        // Cloud routing is explicit: a key plus an explicit Ollama backend
432        // registers search without changing the native fetch default.
433        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        // The SearXNG search backend registers regardless of OLLAMA_API_KEY —
477        // reachability is a call-time concern, not a registration one.
478        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}