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