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;
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/// Implemented by every tool that the model can call. All tools are
44/// `Send + Sync` — they run across tokio `select!` branches inside
45/// the effect runner.
46#[async_trait]
47pub trait ToolExecutor: Send + Sync {
48    /// Canonical name the model uses to call this tool. Matches
49    /// `schema().name` exactly.
50    fn name(&self) -> &'static str;
51
52    /// JSON-schema description the model sees in the outgoing
53    /// request. Adapters translate this into provider-native shape
54    /// (Anthropic's `type: "custom"`, Gemini's `function_declarations`,
55    /// OpenAI's flat `tools`, Ollama's function calling). The same
56    /// `ToolDefinition` feeds all four.
57    fn schema(&self) -> ToolDefinition;
58
59    /// True for tools that exist for internal dispatch only and
60    /// should NOT be advertised to the model (e.g. the MCP proxy
61    /// router, which fronts every `mcp__server__tool` call — the
62    /// individual MCP tools are advertised separately from
63    /// `state.mcp.servers`). Default `false`.
64    fn is_internal(&self) -> bool {
65        false
66    }
67
68    /// Run the tool. The returned `ToolOutcome` is passed verbatim
69    /// into `Msg::ToolFinished` — there's no error-to-outcome
70    /// conversion happening outside this function.
71    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome;
72}
73
74/// Registry of dispatchable tools. Single source of truth for what
75/// the model sees AND what handles a call when the model issues it.
76/// Built once at startup; read-only after that.
77pub struct ToolRegistry {
78    entries: HashMap<&'static str, Arc<dyn ToolExecutor>>,
79    /// Startup-resolved web routing/viability shared by the parent registry,
80    /// its provider-facing definitions, UI diagnostics, and every child
81    /// registry. Keeping the backend clients here prevents credentials or
82    /// environment changes from silently re-resolving a different route.
83    web_capabilities: Option<Arc<web::WebCapabilities>>,
84    /// Direct handle to the subagent spawner (also reachable through the
85    /// `agent` tool entry, but `dyn ToolExecutor` can't be downcast). The
86    /// effect layer uses it to service `Cmd::KillBackgroundAgent`. `None`
87    /// in registries built without a spawner (child registries, tests).
88    subagent_spawner: Option<Arc<subagent::SubagentSpawner>>,
89}
90
91impl ToolRegistry {
92    pub fn new() -> Self {
93        Self {
94            entries: HashMap::new(),
95            web_capabilities: None,
96            subagent_spawner: None,
97        }
98    }
99
100    pub fn web_capabilities(&self) -> Option<&web::WebCapabilities> {
101        self.web_capabilities.as_deref()
102    }
103
104    pub fn subagent_spawner(&self) -> Option<&Arc<subagent::SubagentSpawner>> {
105        self.subagent_spawner.as_ref()
106    }
107
108    pub fn register(&mut self, tool: Arc<dyn ToolExecutor>) {
109        self.entries.insert(tool.name(), tool);
110    }
111
112    pub fn get(&self, name: &str) -> Option<Arc<dyn ToolExecutor>> {
113        self.entries.get(name).cloned()
114    }
115
116    pub fn len(&self) -> usize {
117        self.entries.len()
118    }
119
120    pub fn is_empty(&self) -> bool {
121        self.entries.is_empty()
122    }
123
124    pub fn names(&self) -> impl Iterator<Item = &'static str> + '_ {
125        self.entries.keys().copied()
126    }
127
128    /// Emit every user-facing tool's schema, for inclusion in an
129    /// outgoing `ChatRequest.tools`. Effect runner calls this before
130    /// dispatching `Cmd::CallModel` so the model always sees the
131    /// same list the runner can dispatch. Internal routers (the MCP
132    /// proxy) are filtered out.
133    pub fn describe_all(&self) -> Vec<ToolDefinition> {
134        self.entries
135            .values()
136            .filter(|t| !t.is_internal())
137            .map(|t| t.schema())
138            .collect()
139    }
140}
141
142impl Default for ToolRegistry {
143    fn default() -> Self {
144        let mut r = Self::new();
145        r.register(Arc::new(filesystem::ReadFileTool));
146        r.register(Arc::new(filesystem::WriteFileTool));
147        r.register(Arc::new(apply_patch::ApplyPatchTool));
148        r.register(Arc::new(filesystem::DeleteFileTool));
149        r.register(Arc::new(filesystem::CreateDirectoryTool));
150        r.register(Arc::new(exec::ExecuteCommandTool));
151        r.register(Arc::new(memory::MemoryTool));
152        r.register(Arc::new(ask_user_question::AskUserQuestionTool));
153        // Plan-mode tools are internal (never in describe_all): the reducer
154        // advertises each definition only in the mode where it applies.
155        r.register(Arc::new(enter_plan_mode::EnterPlanModeTool));
156        r.register(Arc::new(exit_plan_mode::ExitPlanModeTool));
157        r.register(Arc::new(tasks::TaskCreateTool));
158        r.register(Arc::new(tasks::TaskUpdateTool));
159        r.register(Arc::new(tasks::TaskListTool));
160        // MCP proxy is the dispatcher for every mcp__server__tool
161        // call; it's internal (not advertised) but MUST be registered
162        // so runtime lookups succeed.
163        r.register(Arc::new(mcp::McpToolProxy));
164        r
165    }
166}
167
168/// Whether the host mermaid process is running interactively (TUI)
169/// or headlessly (one-shot `mermaid run <prompt>` / CI). Controls
170/// which tools get registered: headless mode never advertises
171/// GUI / computer-use tools even when a display probes alive, because
172/// a CI job has no user to watch the screenshot.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum TuiMode {
175    Interactive,
176    Headless,
177}
178
179impl ToolRegistry {
180    /// Register the computer-use tools the given backend can actually drive.
181    /// Screenshot works on every usable backend; the five input tools need
182    /// pointer/keyboard injection (X11/Wayland); `list_windows` is X11-only.
183    /// Advertising a tool the backend can't drive means the model is told it
184    /// has a capability that `bail!`s at call time (#35).
185    fn register_computer_use_tools(&mut self, backend: computer_use::Backend) {
186        let driver = Arc::new(computer_use::ComputerUseDriver::new(backend));
187        self.register(Arc::new(computer_use::ScreenshotTool::new(driver.clone())));
188        if backend.supports_input_injection() {
189            self.register(Arc::new(computer_use::ClickTool::new(driver.clone())));
190            self.register(Arc::new(computer_use::TypeTextTool::new(driver.clone())));
191            self.register(Arc::new(computer_use::PressKeyTool::new(driver.clone())));
192            self.register(Arc::new(computer_use::ScrollTool::new(driver.clone())));
193            self.register(Arc::new(computer_use::MouseMoveTool::new(driver.clone())));
194        }
195        if backend.supports_window_listing() {
196            self.register(Arc::new(computer_use::ListWindowsTool::new(driver.clone())));
197        }
198    }
199
200    /// Config-aware factory. Always registers filesystem + exec +
201    /// the MCP proxy + the subagent tool. Conditionally registers:
202    ///
203    ///   - Viable `web_fetch` and `web_search` capabilities resolved once by
204    ///     `web::WebCapabilities`. Global network denial omits both.
205    ///   - The computer-use tools the detected backend can drive (see
206    ///     `register_computer_use_tools`) iff `mode == Interactive` AND
207    ///     `computer_use::probe()` returns a usable backend.
208    ///
209    /// `providers` is the shared `ProviderFactory` that the effect
210    /// runner also holds; the `SubagentSpawner` needs it so child
211    /// reducer loops hit the same provider cache.
212    ///
213    /// Returns `Arc<Self>` so the effect runner can share a handle
214    /// across turns without cloning the underlying HashMap.
215    pub fn build(
216        config: &crate::app::Config,
217        mode: TuiMode,
218        providers: Arc<crate::providers::ProviderFactory>,
219    ) -> Arc<Self> {
220        let mut r = Self::new();
221        let web_capabilities = Arc::new(web::WebCapabilities::resolve(&config.web));
222        r.register(Arc::new(filesystem::ReadFileTool));
223        r.register(Arc::new(filesystem::WriteFileTool));
224        r.register(Arc::new(apply_patch::ApplyPatchTool));
225        r.register(Arc::new(filesystem::DeleteFileTool));
226        r.register(Arc::new(filesystem::CreateDirectoryTool));
227        r.register(Arc::new(exec::ExecuteCommandTool));
228        r.register(Arc::new(memory::MemoryTool));
229        r.register(Arc::new(ask_user_question::AskUserQuestionTool));
230        r.register(Arc::new(enter_plan_mode::EnterPlanModeTool));
231        r.register(Arc::new(exit_plan_mode::ExitPlanModeTool));
232        r.register(Arc::new(tasks::TaskCreateTool));
233        r.register(Arc::new(tasks::TaskUpdateTool));
234        r.register(Arc::new(tasks::TaskListTool));
235        r.register(Arc::new(mcp::McpToolProxy));
236
237        // `safety.network = "deny"` is a global egress kill-switch, not only
238        // a shell sandbox flag. Omit web capabilities entirely so adapters and
239        // subagents cannot advertise or execute them.
240        if config.safety.network == crate::app::NetworkPolicy::Allow {
241            if let Some(tool) = web_capabilities.fetch_tool() {
242                r.register(Arc::new(tool));
243            }
244            if let Some(tool) = web_capabilities.search_tool() {
245                r.register(Arc::new(tool));
246            }
247        }
248
249        // Computer-use tools only register when (a) the process runs
250        // interactively (Headless CI has no user to watch a screenshot)
251        // AND (b) a display backend passes the startup probe. Failed
252        // probe → tools aren't advertised → model can't call them.
253        if mode == TuiMode::Interactive {
254            let backend = computer_use::probe();
255            if backend.is_usable() {
256                r.register_computer_use_tools(backend);
257            }
258        }
259
260        // Subagents: always register. Depth + breadth caps live on
261        // `SubagentSpawner`; the tool itself is harmless when nobody
262        // calls it. Headless runs do register the agent — a CI prompt
263        // may still delegate to subagents for batched work.
264        let spawner = Arc::new(subagent::SubagentSpawner::new(
265            providers,
266            Arc::clone(&web_capabilities),
267        ));
268        r.register(Arc::new(subagent::SubagentTool::new(spawner.clone())));
269        r.subagent_spawner = Some(spawner);
270        r.web_capabilities = Some(web_capabilities);
271
272        Arc::new(r)
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[test]
281    fn default_registry_has_builtin_tools() {
282        let r = ToolRegistry::default();
283        for name in &[
284            "read_file",
285            "write_file",
286            "apply_patch",
287            "delete_file",
288            "create_directory",
289            "execute_command",
290            "memory",
291        ] {
292            assert!(r.get(name).is_some(), "missing: {}", name);
293        }
294        assert!(r.get("not_a_tool").is_none());
295        assert!(r.len() >= 6);
296    }
297
298    #[test]
299    fn computer_use_registration_is_selective_per_backend() {
300        use computer_use::Backend;
301        let reg = |b: Backend| {
302            let mut r = ToolRegistry::new();
303            r.register_computer_use_tools(b);
304            r
305        };
306
307        // macOS: only screenshot — the input verbs + list_windows bail at
308        // runtime, so advertising them just wastes the model's turns (#35).
309        let mac = reg(Backend::MacOS);
310        assert!(mac.get("screenshot").is_some());
311        for t in [
312            "click",
313            "type_text",
314            "press_key",
315            "scroll",
316            "mouse_move",
317            "list_windows",
318        ] {
319            assert!(mac.get(t).is_none(), "macOS must not advertise {t}");
320        }
321
322        // Wayland: input tools, but no list_windows (no portable enumeration).
323        let way = reg(Backend::Wayland);
324        assert!(way.get("click").is_some());
325        assert!(way.get("list_windows").is_none());
326
327        // X11: all seven.
328        let x11 = reg(Backend::X11);
329        for t in [
330            "screenshot",
331            "click",
332            "type_text",
333            "press_key",
334            "scroll",
335            "mouse_move",
336            "list_windows",
337        ] {
338            assert!(x11.get(t).is_some(), "X11 missing {t}");
339        }
340    }
341
342    #[test]
343    fn describe_all_returns_one_per_user_facing_tool() {
344        let r = ToolRegistry::default();
345        let schemas = r.describe_all();
346        // mcp_proxy is registered but internal — filtered out of
347        // describe_all. So len() includes it but schemas don't.
348        let visible = r
349            .names()
350            .filter(|n| r.get(n).map(|t| !t.is_internal()).unwrap_or(false))
351            .count();
352        assert_eq!(schemas.len(), visible);
353        for schema in &schemas {
354            assert!(
355                r.get(&schema.name).is_some(),
356                "schema for unknown tool: {}",
357                schema.name
358            );
359        }
360    }
361
362    #[test]
363    fn mcp_proxy_is_registered_but_internal() {
364        let r = ToolRegistry::default();
365        let proxy = r.get("mcp_proxy").expect("mcp_proxy registered");
366        assert!(proxy.is_internal());
367        assert!(!r.describe_all().iter().any(|s| s.name == "mcp_proxy"));
368    }
369
370    #[test]
371    fn schema_name_matches_executor_name() {
372        let r = ToolRegistry::default();
373        for name in r.names() {
374            let tool = r.get(name).unwrap();
375            assert_eq!(tool.name(), tool.schema().name.as_str());
376        }
377    }
378
379    /// Serialization guard for tests that mutate the `OLLAMA_API_KEY`
380    /// env var. Cargo's default test harness runs tests in parallel
381    /// threads inside one process; without this mutex two env-touching
382    /// tests would race and occasionally flip each other's expectations.
383    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
384
385    #[test]
386    fn build_registers_zero_config_web_tools_without_key() {
387        // Both web tools register with no OLLAMA_API_KEY: web_fetch is native,
388        // and web_search defaults to `auto`, which falls back to a managed local
389        // SearXNG (the process starts lazily at call time, not here).
390        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
391        let prior = std::env::var("OLLAMA_API_KEY").ok();
392        unsafe {
393            std::env::remove_var("OLLAMA_API_KEY");
394        }
395        let cfg = crate::app::Config::default();
396        let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
397        let r = ToolRegistry::build(&cfg, TuiMode::Headless, providers);
398        assert!(
399            r.get("web_fetch").is_some(),
400            "native web_fetch registers without a key"
401        );
402        assert_eq!(
403            r.get("web_search").is_some(),
404            crate::searxng::managed_backend_viability().is_ok(),
405            "auto web_search registers only when managed SearXNG is viable"
406        );
407        assert!(r.get("read_file").is_some());
408        assert!(r.get("execute_command").is_some());
409        let web = r
410            .web_capabilities()
411            .expect("config-aware registries retain the resolved web status");
412        assert_eq!(web.fetch.backend, "native");
413        assert_eq!(web.search.backend, "managed_searxng");
414        unsafe {
415            if let Some(v) = prior {
416                std::env::set_var("OLLAMA_API_KEY", v);
417            }
418        }
419    }
420
421    #[test]
422    fn build_registers_ollama_web_search_with_key() {
423        // Cloud routing is explicit: a key plus an explicit Ollama backend
424        // registers search without changing the native fetch default.
425        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
426        let prior = std::env::var("OLLAMA_API_KEY").ok();
427        unsafe {
428            std::env::set_var("OLLAMA_API_KEY", "test-key-build");
429        }
430        let mut cfg = crate::app::Config::default();
431        cfg.web.search_backend = crate::app::SearchBackend::Ollama;
432        let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
433        let r = ToolRegistry::build(&cfg, TuiMode::Interactive, providers);
434        assert!(r.get("web_search").is_some(), "web_search registered");
435        assert!(r.get("web_fetch").is_some(), "web_fetch registered");
436        unsafe {
437            match prior {
438                Some(v) => std::env::set_var("OLLAMA_API_KEY", v),
439                None => std::env::remove_var("OLLAMA_API_KEY"),
440            }
441        }
442    }
443
444    #[test]
445    fn auto_search_never_selects_cloud_just_because_a_key_exists() {
446        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
447        let prior = std::env::var("OLLAMA_API_KEY").ok();
448        unsafe {
449            std::env::set_var("OLLAMA_API_KEY", "test-key-must-not-route");
450        }
451        let cfg = crate::app::Config::default();
452        let capabilities = web::WebCapabilities::resolve(&cfg.web);
453        assert_eq!(capabilities.search.backend, "managed_searxng");
454        assert_eq!(
455            capabilities.search.available,
456            crate::searxng::managed_backend_viability().is_ok()
457        );
458        unsafe {
459            match prior {
460                Some(value) => std::env::set_var("OLLAMA_API_KEY", value),
461                None => std::env::remove_var("OLLAMA_API_KEY"),
462            }
463        }
464    }
465
466    #[test]
467    fn build_registers_searxng_web_search_without_key() {
468        // The SearXNG search backend registers regardless of OLLAMA_API_KEY —
469        // reachability is a call-time concern, not a registration one.
470        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
471        let prior = std::env::var("OLLAMA_API_KEY").ok();
472        unsafe {
473            std::env::remove_var("OLLAMA_API_KEY");
474        }
475        let mut cfg = crate::app::Config::default();
476        cfg.web.search_backend = crate::app::SearchBackend::Searxng;
477        let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
478        let r = ToolRegistry::build(&cfg, TuiMode::Headless, providers);
479        assert!(
480            r.get("web_search").is_some(),
481            "searxng web_search registers without a key"
482        );
483        assert!(
484            r.get("web_fetch").is_some(),
485            "native web_fetch still present"
486        );
487        unsafe {
488            if let Some(v) = prior {
489                std::env::set_var("OLLAMA_API_KEY", v);
490            }
491        }
492    }
493
494    #[test]
495    fn network_deny_omits_all_web_capabilities() {
496        let mut cfg = crate::app::Config::default();
497        cfg.safety.network = crate::app::NetworkPolicy::Deny;
498        cfg.web.search_backend = crate::app::SearchBackend::Searxng;
499        let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
500        let registry = ToolRegistry::build(&cfg, TuiMode::Headless, providers);
501        assert!(registry.get("web_fetch").is_none());
502        assert!(registry.get("web_search").is_none());
503        assert!(registry.get("read_file").is_some());
504    }
505}