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    /// Direct handle to the subagent spawner (also reachable through the
80    /// `agent` tool entry, but `dyn ToolExecutor` can't be downcast). The
81    /// effect layer uses it to service `Cmd::KillBackgroundAgent`. `None`
82    /// in registries built without a spawner (child registries, tests).
83    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    /// Emit every user-facing tool's schema, for inclusion in an
119    /// outgoing `ChatRequest.tools`. Effect runner calls this before
120    /// dispatching `Cmd::CallModel` so the model always sees the
121    /// same list the runner can dispatch. Internal routers (the MCP
122    /// proxy) are filtered out.
123    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        // Plan-mode tools are internal (never in describe_all): the reducer
144        // advertises each definition only in the mode where it applies.
145        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        // MCP proxy is the dispatcher for every mcp__server__tool
151        // call; it's internal (not advertised) but MUST be registered
152        // so runtime lookups succeed.
153        r.register(Arc::new(mcp::McpToolProxy));
154        r
155    }
156}
157
158/// Whether the host mermaid process is running interactively (TUI)
159/// or headlessly (one-shot `mermaid run <prompt>` / CI). Controls
160/// which tools get registered: headless mode never advertises
161/// GUI / computer-use tools even when a display probes alive, because
162/// a CI job has no user to watch the screenshot.
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum TuiMode {
165    Interactive,
166    Headless,
167}
168
169impl ToolRegistry {
170    /// Register the computer-use tools the given backend can actually drive.
171    /// Screenshot works on every usable backend; the five input tools need
172    /// pointer/keyboard injection (X11/Wayland); `list_windows` is X11-only.
173    /// Advertising a tool the backend can't drive means the model is told it
174    /// has a capability that `bail!`s at call time (#35).
175    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    /// Config-aware factory. Always registers filesystem + exec +
191    /// the MCP proxy + the subagent tool. Conditionally registers:
192    ///
193    ///   - `web_fetch` and `web_search` per `config.web`. `web_fetch`
194    ///     defaults to the native backend (always registered, no key);
195    ///     `web_search` defaults to Ollama Cloud (registered iff
196    ///     `OLLAMA_API_KEY` resolves) or SearXNG (always). See
197    ///     `web::web_fetch_tool` / `web::web_search_tool`.
198    ///   - The computer-use tools the detected backend can drive (see
199    ///     `register_computer_use_tools`) iff `mode == Interactive` AND
200    ///     `computer_use::probe()` returns a usable backend.
201    ///
202    /// `providers` is the shared `ProviderFactory` that the effect
203    /// runner also holds; the `SubagentSpawner` needs it so child
204    /// reducer loops hit the same provider cache.
205    ///
206    /// Returns `Arc<Self>` so the effect runner can share a handle
207    /// across turns without cloning the underlying HashMap.
208    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        // Computer-use tools only register when (a) the process runs
237        // interactively (Headless CI has no user to watch a screenshot)
238        // AND (b) a display backend passes the startup probe. Failed
239        // probe → tools aren't advertised → model can't call them.
240        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        // Subagents: always register. Depth + breadth caps live on
248        // `SubagentSpawner`; the tool itself is harmless when nobody
249        // calls it. Headless runs do register the agent — a CI prompt
250        // may still delegate to subagents for batched work.
251        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        // macOS: only screenshot — the input verbs + list_windows bail at
291        // runtime, so advertising them just wastes the model's turns (#35).
292        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        // Wayland: input tools, but no list_windows (no portable enumeration).
306        let way = reg(Backend::Wayland);
307        assert!(way.get("click").is_some());
308        assert!(way.get("list_windows").is_none());
309
310        // X11: all seven.
311        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        // mcp_proxy is registered but internal — filtered out of
330        // describe_all. So len() includes it but schemas don't.
331        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    /// Serialization guard for tests that mutate the `OLLAMA_API_KEY`
363    /// env var. Cargo's default test harness runs tests in parallel
364    /// threads inside one process; without this mutex two env-touching
365    /// tests would race and occasionally flip each other's expectations.
366    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
367
368    #[test]
369    fn build_registers_zero_config_web_tools_without_key() {
370        // Both web tools register with no OLLAMA_API_KEY: web_fetch is native,
371        // and web_search defaults to `auto`, which falls back to a managed local
372        // SearXNG (the container starts lazily at call time, not here).
373        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        // With a key, the default Ollama search backend registers too.
401        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        // The SearXNG search backend registers regardless of OLLAMA_API_KEY —
422        // reachability is a call-time concern, not a registration one.
423        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}