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 computer_use;
19pub mod exec;
20pub mod filesystem;
21pub mod mcp;
22pub mod memory;
23pub mod path_safety;
24pub mod policy_gate;
25pub mod subagent;
26pub mod web;
27pub mod web_client;
28
29use async_trait::async_trait;
30use std::collections::HashMap;
31use std::sync::Arc;
32
33use crate::domain::{ToolDefinition, ToolOutcome};
34
35use super::ctx::ExecContext;
36
37/// Implemented by every tool that the model can call. All tools are
38/// `Send + Sync` — they run across tokio `select!` branches inside
39/// the effect runner.
40#[async_trait]
41pub trait ToolExecutor: Send + Sync {
42    /// Canonical name the model uses to call this tool. Matches
43    /// `schema().name` exactly.
44    fn name(&self) -> &'static str;
45
46    /// JSON-schema description the model sees in the outgoing
47    /// request. Adapters translate this into provider-native shape
48    /// (Anthropic's `type: "custom"`, Gemini's `function_declarations`,
49    /// OpenAI's flat `tools`, Ollama's function calling). The same
50    /// `ToolDefinition` feeds all four.
51    fn schema(&self) -> ToolDefinition;
52
53    /// True for tools that exist for internal dispatch only and
54    /// should NOT be advertised to the model (e.g. the MCP proxy
55    /// router, which fronts every `mcp__server__tool` call — the
56    /// individual MCP tools are advertised separately from
57    /// `state.mcp.servers`). Default `false`.
58    fn is_internal(&self) -> bool {
59        false
60    }
61
62    /// Run the tool. The returned `ToolOutcome` is passed verbatim
63    /// into `Msg::ToolFinished` — there's no error-to-outcome
64    /// conversion happening outside this function.
65    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome;
66}
67
68/// Registry of dispatchable tools. Single source of truth for what
69/// the model sees AND what handles a call when the model issues it.
70/// Built once at startup; read-only after that.
71pub struct ToolRegistry {
72    entries: HashMap<&'static str, Arc<dyn ToolExecutor>>,
73}
74
75impl ToolRegistry {
76    pub fn new() -> Self {
77        Self {
78            entries: HashMap::new(),
79        }
80    }
81
82    pub fn register(&mut self, tool: Arc<dyn ToolExecutor>) {
83        self.entries.insert(tool.name(), tool);
84    }
85
86    pub fn get(&self, name: &str) -> Option<Arc<dyn ToolExecutor>> {
87        self.entries.get(name).cloned()
88    }
89
90    pub fn len(&self) -> usize {
91        self.entries.len()
92    }
93
94    pub fn is_empty(&self) -> bool {
95        self.entries.is_empty()
96    }
97
98    pub fn names(&self) -> impl Iterator<Item = &'static str> + '_ {
99        self.entries.keys().copied()
100    }
101
102    /// Emit every user-facing tool's schema, for inclusion in an
103    /// outgoing `ChatRequest.tools`. Effect runner calls this before
104    /// dispatching `Cmd::CallModel` so the model always sees the
105    /// same list the runner can dispatch. Internal routers (the MCP
106    /// proxy) are filtered out.
107    pub fn describe_all(&self) -> Vec<ToolDefinition> {
108        self.entries
109            .values()
110            .filter(|t| !t.is_internal())
111            .map(|t| t.schema())
112            .collect()
113    }
114}
115
116impl Default for ToolRegistry {
117    fn default() -> Self {
118        let mut r = Self::new();
119        r.register(Arc::new(filesystem::ReadFileTool));
120        r.register(Arc::new(filesystem::WriteFileTool));
121        r.register(Arc::new(filesystem::EditFileTool));
122        r.register(Arc::new(filesystem::DeleteFileTool));
123        r.register(Arc::new(filesystem::CreateDirectoryTool));
124        r.register(Arc::new(exec::ExecuteCommandTool));
125        r.register(Arc::new(memory::MemoryTool));
126        // MCP proxy is the dispatcher for every mcp__server__tool
127        // call; it's internal (not advertised) but MUST be registered
128        // so runtime lookups succeed.
129        r.register(Arc::new(mcp::McpToolProxy));
130        r
131    }
132}
133
134/// Whether the host mermaid process is running interactively (TUI)
135/// or headlessly (one-shot `mermaid run <prompt>` / CI). Controls
136/// which tools get registered: headless mode never advertises
137/// GUI / computer-use tools even when a display probes alive, because
138/// a CI job has no user to watch the screenshot.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum TuiMode {
141    Interactive,
142    Headless,
143}
144
145impl ToolRegistry {
146    /// Register the computer-use tools the given backend can actually drive.
147    /// Screenshot works on every usable backend; the five input tools need
148    /// pointer/keyboard injection (X11/Wayland); `list_windows` is X11-only.
149    /// Advertising a tool the backend can't drive means the model is told it
150    /// has a capability that `bail!`s at call time (#35).
151    fn register_computer_use_tools(&mut self, backend: computer_use::Backend) {
152        let driver = Arc::new(computer_use::ComputerUseDriver::new(backend));
153        self.register(Arc::new(computer_use::ScreenshotTool::new(driver.clone())));
154        if backend.supports_input_injection() {
155            self.register(Arc::new(computer_use::ClickTool::new(driver.clone())));
156            self.register(Arc::new(computer_use::TypeTextTool::new(driver.clone())));
157            self.register(Arc::new(computer_use::PressKeyTool::new(driver.clone())));
158            self.register(Arc::new(computer_use::ScrollTool::new(driver.clone())));
159            self.register(Arc::new(computer_use::MouseMoveTool::new(driver.clone())));
160        }
161        if backend.supports_window_listing() {
162            self.register(Arc::new(computer_use::ListWindowsTool::new(driver.clone())));
163        }
164    }
165
166    /// Config-aware factory. Always registers filesystem + exec +
167    /// the MCP proxy + the subagent tool. Conditionally registers:
168    ///
169    ///   - `web_search` + `web_fetch` iff `OLLAMA_API_KEY` resolves
170    ///     (via `utils::resolve_api_key`). Without a key, the tools
171    ///     would error on every call — so we don't advertise them at
172    ///     all.
173    ///   - The computer-use tools the detected backend can drive (see
174    ///     `register_computer_use_tools`) iff `mode == Interactive` AND
175    ///     `computer_use::probe()` returns a usable backend.
176    ///
177    /// `providers` is the shared `ProviderFactory` that the effect
178    /// runner also holds; the `SubagentSpawner` needs it so child
179    /// reducer loops hit the same provider cache.
180    ///
181    /// Returns `Arc<Self>` so the effect runner can share a handle
182    /// across turns without cloning the underlying HashMap.
183    pub fn build(
184        _config: &crate::app::Config,
185        mode: TuiMode,
186        providers: Arc<crate::providers::ProviderFactory>,
187    ) -> Arc<Self> {
188        let mut r = Self::new();
189        r.register(Arc::new(filesystem::ReadFileTool));
190        r.register(Arc::new(filesystem::WriteFileTool));
191        r.register(Arc::new(filesystem::EditFileTool));
192        r.register(Arc::new(filesystem::DeleteFileTool));
193        r.register(Arc::new(filesystem::CreateDirectoryTool));
194        r.register(Arc::new(exec::ExecuteCommandTool));
195        r.register(Arc::new(memory::MemoryTool));
196        r.register(Arc::new(mcp::McpToolProxy));
197
198        if let Some(key) = crate::utils::resolve_api_key("OLLAMA_API_KEY", None) {
199            r.register(Arc::new(web::WebSearchTool::new(key.clone())));
200            r.register(Arc::new(web::WebFetchTool::new(key)));
201        }
202
203        // Computer-use tools only register when (a) the process runs
204        // interactively (Headless CI has no user to watch a screenshot)
205        // AND (b) a display backend passes the startup probe. Failed
206        // probe → tools aren't advertised → model can't call them.
207        if mode == TuiMode::Interactive {
208            let backend = computer_use::probe();
209            if backend.is_usable() {
210                r.register_computer_use_tools(backend);
211            }
212        }
213
214        // Subagents: always register. Depth + breadth caps live on
215        // `SubagentSpawner`; the tool itself is harmless when nobody
216        // calls it. Headless runs do register the agent — a CI prompt
217        // may still delegate to subagents for batched work.
218        let spawner = Arc::new(subagent::SubagentSpawner::new(providers));
219        r.register(Arc::new(subagent::SubagentTool::new(spawner)));
220
221        Arc::new(r)
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn default_registry_has_builtin_tools() {
231        let r = ToolRegistry::default();
232        for name in &[
233            "read_file",
234            "write_file",
235            "edit_file",
236            "delete_file",
237            "create_directory",
238            "execute_command",
239            "memory",
240        ] {
241            assert!(r.get(name).is_some(), "missing: {}", name);
242        }
243        assert!(r.get("not_a_tool").is_none());
244        assert!(r.len() >= 6);
245    }
246
247    #[test]
248    fn computer_use_registration_is_selective_per_backend() {
249        use computer_use::Backend;
250        let reg = |b: Backend| {
251            let mut r = ToolRegistry::new();
252            r.register_computer_use_tools(b);
253            r
254        };
255
256        // macOS: only screenshot — the input verbs + list_windows bail at
257        // runtime, so advertising them just wastes the model's turns (#35).
258        let mac = reg(Backend::MacOS);
259        assert!(mac.get("screenshot").is_some());
260        for t in [
261            "click",
262            "type_text",
263            "press_key",
264            "scroll",
265            "mouse_move",
266            "list_windows",
267        ] {
268            assert!(mac.get(t).is_none(), "macOS must not advertise {t}");
269        }
270
271        // Wayland: input tools, but no list_windows (no portable enumeration).
272        let way = reg(Backend::Wayland);
273        assert!(way.get("click").is_some());
274        assert!(way.get("list_windows").is_none());
275
276        // X11: all seven.
277        let x11 = reg(Backend::X11);
278        for t in [
279            "screenshot",
280            "click",
281            "type_text",
282            "press_key",
283            "scroll",
284            "mouse_move",
285            "list_windows",
286        ] {
287            assert!(x11.get(t).is_some(), "X11 missing {t}");
288        }
289    }
290
291    #[test]
292    fn describe_all_returns_one_per_user_facing_tool() {
293        let r = ToolRegistry::default();
294        let schemas = r.describe_all();
295        // mcp_proxy is registered but internal — filtered out of
296        // describe_all. So len() includes it but schemas don't.
297        let visible = r
298            .names()
299            .filter(|n| r.get(n).map(|t| !t.is_internal()).unwrap_or(false))
300            .count();
301        assert_eq!(schemas.len(), visible);
302        for schema in &schemas {
303            assert!(
304                r.get(&schema.name).is_some(),
305                "schema for unknown tool: {}",
306                schema.name
307            );
308        }
309    }
310
311    #[test]
312    fn mcp_proxy_is_registered_but_internal() {
313        let r = ToolRegistry::default();
314        let proxy = r.get("mcp_proxy").expect("mcp_proxy registered");
315        assert!(proxy.is_internal());
316        assert!(!r.describe_all().iter().any(|s| s.name == "mcp_proxy"));
317    }
318
319    #[test]
320    fn schema_name_matches_executor_name() {
321        let r = ToolRegistry::default();
322        for name in r.names() {
323            let tool = r.get(name).unwrap();
324            assert_eq!(tool.name(), tool.schema().name.as_str());
325        }
326    }
327
328    /// Serialization guard for tests that mutate the `OLLAMA_API_KEY`
329    /// env var. Cargo's default test harness runs tests in parallel
330    /// threads inside one process; without this mutex two env-touching
331    /// tests would race and occasionally flip each other's expectations.
332    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
333
334    #[test]
335    fn build_registers_web_tools_when_key_present() {
336        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
337        let prior = std::env::var("OLLAMA_API_KEY").ok();
338        unsafe {
339            std::env::set_var("OLLAMA_API_KEY", "test-key-build");
340        }
341        let cfg = crate::app::Config::default();
342        let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
343        let r = ToolRegistry::build(&cfg, TuiMode::Interactive, providers);
344        assert!(r.get("web_search").is_some(), "web_search registered");
345        assert!(r.get("web_fetch").is_some(), "web_fetch registered");
346        unsafe {
347            match prior {
348                Some(v) => std::env::set_var("OLLAMA_API_KEY", v),
349                None => std::env::remove_var("OLLAMA_API_KEY"),
350            }
351        }
352    }
353
354    #[test]
355    fn build_skips_web_tools_without_key() {
356        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
357        let prior = std::env::var("OLLAMA_API_KEY").ok();
358        unsafe {
359            std::env::remove_var("OLLAMA_API_KEY");
360        }
361        let cfg = crate::app::Config::default();
362        let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
363        let r = ToolRegistry::build(&cfg, TuiMode::Headless, providers);
364        assert!(r.get("web_search").is_none(), "web_search skipped");
365        assert!(r.get("web_fetch").is_none(), "web_fetch skipped");
366        assert!(r.get("read_file").is_some());
367        assert!(r.get("execute_command").is_some());
368        unsafe {
369            if let Some(v) = prior {
370                std::env::set_var("OLLAMA_API_KEY", v);
371            }
372        }
373    }
374}