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::build()` — the ONE factory production
13//!      uses. There is no second registry to forget.
14//!
15//! Because `schema()` lives on the same trait as `execute()`, the
16//! name + JSON schema the model sees cannot drift from the handler
17//! that runs when the model calls it. Single source of truth.
18
19pub mod apply_patch;
20pub mod ask_user_question;
21pub mod context;
22pub mod exec;
23pub mod filesystem;
24pub mod mcp;
25pub mod memory;
26pub mod path_lock;
27pub mod path_safety;
28pub mod policy_gate;
29pub mod subagent;
30pub mod tasks;
31pub mod web;
32pub mod web_client;
33pub mod workspace;
34
35use async_trait::async_trait;
36use std::collections::HashMap;
37use std::sync::Arc;
38
39use mermaid_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    /// Teaching errors for tools that were CONSIDERED at build time and
80    /// deliberately not registered (backend unavailable, network denied,
81    /// filtered out of a child registry). Dispatch returns the reason when
82    /// the model calls one, so the model learns why the tool is absent and
83    /// what to do about it — a bare "unknown tool" reads as a schema bug
84    /// and was observed driving models to fabricate results instead of
85    /// reporting the gap.
86    unavailable: HashMap<&'static str, String>,
87    /// Startup-resolved web routing/viability shared by the parent registry,
88    /// its provider-facing definitions, UI diagnostics, and every child
89    /// registry. Keeping the backend clients here prevents credentials or
90    /// environment changes from silently re-resolving a different route.
91    web_capabilities: Option<Arc<web::WebCapabilities>>,
92    /// Direct handle to the subagent spawner (also reachable through the
93    /// `agent` tool entry, but `dyn ToolExecutor` can't be downcast). The
94    /// effect layer uses it to service `Cmd::KillBackgroundAgent`. `None`
95    /// in registries built without a spawner (child registries, tests).
96    subagent_spawner: Option<Arc<subagent::SubagentSpawner>>,
97}
98
99/// An empty registry. Every session's registry comes from [`ToolRegistry::build`];
100/// this exists for callers that assemble one by hand (stubs, the subagent
101/// child registry) and for the `Default` convention, never as a second list
102/// of built-in tools.
103impl Default for ToolRegistry {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109impl ToolRegistry {
110    #[must_use]
111    pub fn new() -> Self {
112        Self {
113            entries: HashMap::new(),
114            unavailable: HashMap::new(),
115            web_capabilities: None,
116            subagent_spawner: None,
117        }
118    }
119
120    #[must_use]
121    pub fn web_capabilities(&self) -> Option<&web::WebCapabilities> {
122        self.web_capabilities.as_deref()
123    }
124
125    #[must_use]
126    pub fn subagent_spawner(&self) -> Option<&Arc<subagent::SubagentSpawner>> {
127        self.subagent_spawner.as_ref()
128    }
129
130    pub fn register(&mut self, tool: Arc<dyn ToolExecutor>) {
131        self.entries.insert(tool.name(), tool);
132    }
133
134    /// Record why a tool that could exist in this registry deliberately does
135    /// not. The reason is model-facing: it must name the cause and the
136    /// remediation, because it is returned verbatim when the model calls the
137    /// absent tool.
138    pub fn note_unavailable(&mut self, tool: &'static str, reason: impl Into<String>) {
139        self.unavailable.insert(tool, reason.into());
140    }
141
142    #[must_use]
143    pub fn unavailable_reason(&self, name: &str) -> Option<&str> {
144        self.unavailable.get(name).map(String::as_str)
145    }
146
147    /// The outcome for a call this registry cannot dispatch: the recorded
148    /// teaching error when the tool was deliberately omitted, else the plain
149    /// unknown-tool error. `called_name` is the name the model used — for
150    /// MCP calls it differs from the internal `mcp_proxy` routing key, and
151    /// the model should see the name it actually wrote.
152    #[must_use]
153    pub fn unknown_tool_outcome(&self, tool_key: &str, called_name: &str) -> ToolOutcome {
154        self.unavailable.get(tool_key).map_or_else(
155            || ToolOutcome::error(format!("unknown tool: {called_name}"), None),
156            |reason| ToolOutcome::error(format!("{called_name} is not available: {reason}"), None),
157        )
158    }
159
160    #[must_use]
161    pub fn get(&self, name: &str) -> Option<Arc<dyn ToolExecutor>> {
162        self.entries.get(name).cloned()
163    }
164
165    #[must_use]
166    pub fn len(&self) -> usize {
167        self.entries.len()
168    }
169
170    #[must_use]
171    pub fn is_empty(&self) -> bool {
172        self.entries.is_empty()
173    }
174
175    pub fn names(&self) -> impl Iterator<Item = &'static str> + '_ {
176        self.entries.keys().copied()
177    }
178
179    /// Emit every user-facing tool's schema, for inclusion in an
180    /// outgoing `ChatRequest.tools`. Effect runner calls this before
181    /// dispatching `Cmd::CallModel` so the model always sees the
182    /// same list the runner can dispatch. Internal routers (the MCP
183    /// proxy) are filtered out.
184    #[must_use]
185    pub fn describe_all(&self) -> Vec<ToolDefinition> {
186        self.entries
187            .values()
188            .filter(|t| !t.is_internal())
189            .map(|t| t.schema())
190            .collect()
191    }
192}
193
194impl ToolRegistry {
195    /// Config-aware factory. Always registers filesystem + exec +
196    /// the MCP proxy + the subagent tool. Conditionally registers:
197    ///
198    ///   - Viable `web_fetch` and `web_search` capabilities resolved once by
199    ///     `web::WebCapabilities`. Global network denial omits both.
200    ///
201    /// `providers` is the shared `ProviderFactory` that the effect
202    /// runner also holds; the `SubagentSpawner` needs it so child
203    /// reducer loops hit the same provider cache.
204    ///
205    /// Returns `Arc<Self>` so the effect runner can share a handle
206    /// across turns without cloning the underlying `HashMap`.
207    pub fn build(
208        config: &mermaid_domain::Config,
209        providers: Arc<crate::providers::ProviderFactory>,
210    ) -> Arc<Self> {
211        let mut r = Self::new();
212        let web_capabilities = Arc::new(web::WebCapabilities::resolve(&config.web));
213        r.register(Arc::new(filesystem::ReadFileTool));
214        r.register(Arc::new(filesystem::WriteFileTool));
215        r.register(Arc::new(filesystem::EditFileTool));
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(tasks::TaskCreateTool));
223        r.register(Arc::new(tasks::TaskUpdateTool));
224        r.register(Arc::new(tasks::TaskListTool));
225        r.register(Arc::new(context::ContextArchiveTool));
226        r.register(Arc::new(context::CompactContextTool));
227        r.register(Arc::new(mcp::McpToolProxy));
228
229        // `safety.network = "deny"` is a global egress kill-switch, not only
230        // a shell sandbox flag. Omit web capabilities entirely so adapters and
231        // subagents cannot advertise or execute them — and record why, so a
232        // model that calls one anyway is taught the cause instead of shown a
233        // bare "unknown tool".
234        if config.safety.network == mermaid_domain::NetworkPolicy::Allow {
235            match web_capabilities.fetch_tool() {
236                Some(tool) => r.register(Arc::new(tool)),
237                None => r.note_unavailable(
238                    "web_fetch",
239                    web_capabilities.fetch.absence_reason("web_fetch"),
240                ),
241            }
242            match web_capabilities.search_tool() {
243                Some(tool) => r.register(Arc::new(tool)),
244                None => r.note_unavailable(
245                    "web_search",
246                    web_capabilities.search.absence_reason("web_search"),
247                ),
248            }
249        } else {
250            for tool in ["web_fetch", "web_search"] {
251                r.note_unavailable(
252                    tool,
253                    format!(
254                        "{tool} is disabled: network access is off \
255                         (safety.network = \"deny\" / --no-network)"
256                    ),
257                );
258            }
259        }
260
261        // Subagents: always register. Depth + breadth caps live on
262        // `SubagentSpawner`; the tool itself is harmless when nobody
263        // calls it. Headless runs do register the agent — a CI prompt
264        // may still delegate to subagents for batched work.
265        let spawner = Arc::new(subagent::SubagentSpawner::new(
266            providers,
267            Arc::clone(&web_capabilities),
268        ));
269        r.register(Arc::new(subagent::SubagentTool::new(spawner.clone())));
270        r.subagent_spawner = Some(spawner);
271        r.web_capabilities = Some(web_capabilities);
272
273        Arc::new(r)
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    /// The production registry as a headless session sees it: `build()` with
282    /// a default config and a stub provider factory. Tests go through the same
283    /// factory production does, so a tool registered anywhere else does not
284    /// exist as far as they are concerned.
285    fn headless_registry() -> Arc<ToolRegistry> {
286        let cfg = mermaid_domain::Config::default();
287        let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
288        ToolRegistry::build(&cfg, providers)
289    }
290
291    #[test]
292    fn default_registry_has_builtin_tools() {
293        let r = headless_registry();
294        for name in &[
295            "read_file",
296            "write_file",
297            "edit_file",
298            "apply_patch",
299            "delete_file",
300            "create_directory",
301            "execute_command",
302            "memory",
303        ] {
304            assert!(r.get(name).is_some(), "missing: {name}");
305        }
306        assert!(r.get("not_a_tool").is_none());
307        assert!(r.len() >= 6);
308    }
309
310    #[test]
311    fn describe_all_returns_one_per_user_facing_tool() {
312        let r = headless_registry();
313        let schemas = r.describe_all();
314        // mcp_proxy is registered but internal — filtered out of
315        // describe_all. So len() includes it but schemas don't.
316        let visible = r
317            .names()
318            .filter(|n| r.get(n).map(|t| !t.is_internal()).unwrap_or(false))
319            .count();
320        assert_eq!(schemas.len(), visible);
321        for schema in &schemas {
322            assert!(
323                r.get(&schema.name).is_some(),
324                "schema for unknown tool: {}",
325                schema.name
326            );
327        }
328    }
329
330    #[test]
331    fn mcp_proxy_is_registered_but_internal() {
332        let r = headless_registry();
333        let proxy = r.get("mcp_proxy").expect("mcp_proxy registered");
334        assert!(proxy.is_internal());
335        assert!(!r.describe_all().iter().any(|s| s.name == "mcp_proxy"));
336    }
337
338    #[test]
339    fn schema_name_matches_executor_name() {
340        let r = headless_registry();
341        for name in r.names() {
342            let tool = r.get(name).unwrap();
343            assert_eq!(tool.name(), tool.schema().name.as_str());
344        }
345    }
346
347    /// Serialization guard for tests that mutate the `OLLAMA_API_KEY`
348    /// env var. Cargo's default test harness runs tests in parallel
349    /// threads inside one process; without this mutex two env-touching
350    /// tests would race and occasionally flip each other's expectations.
351    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
352
353    #[test]
354    fn build_registers_zero_config_web_tools_without_key() {
355        // Both web tools register with no OLLAMA_API_KEY: web_fetch is native,
356        // and web_search defaults to `auto`, which falls back to a managed local
357        // SearXNG (the process starts lazily at call time, not here).
358        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
359        let prior = std::env::var("OLLAMA_API_KEY").ok();
360        unsafe {
361            std::env::remove_var("OLLAMA_API_KEY");
362        }
363        let cfg = mermaid_domain::Config::default();
364        let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
365        let r = ToolRegistry::build(&cfg, providers);
366        assert!(
367            r.get("web_fetch").is_some(),
368            "native web_fetch registers without a key"
369        );
370        assert_eq!(
371            r.get("web_search").is_some(),
372            crate::searxng::managed_backend_viability().is_ok(),
373            "auto web_search registers only when managed SearXNG is viable"
374        );
375        assert!(r.get("read_file").is_some());
376        assert!(r.get("execute_command").is_some());
377        let web = r
378            .web_capabilities()
379            .expect("config-aware registries retain the resolved web status");
380        assert_eq!(web.fetch.backend, "native");
381        assert_eq!(web.search.backend, "managed_searxng");
382        unsafe {
383            if let Some(v) = prior {
384                std::env::set_var("OLLAMA_API_KEY", v);
385            }
386        }
387    }
388
389    #[test]
390    fn build_registers_ollama_web_search_with_key() {
391        // Cloud routing is explicit: a key plus an explicit Ollama backend
392        // registers search without changing the native fetch default.
393        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
394        let prior = std::env::var("OLLAMA_API_KEY").ok();
395        unsafe {
396            std::env::set_var("OLLAMA_API_KEY", "test-key-build");
397        }
398        let mut cfg = mermaid_domain::Config::default();
399        cfg.web.search_backend = mermaid_domain::SearchBackend::Ollama;
400        let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
401        let r = ToolRegistry::build(&cfg, providers);
402        assert!(r.get("web_search").is_some(), "web_search registered");
403        assert!(r.get("web_fetch").is_some(), "web_fetch registered");
404        unsafe {
405            match prior {
406                Some(v) => std::env::set_var("OLLAMA_API_KEY", v),
407                None => std::env::remove_var("OLLAMA_API_KEY"),
408            }
409        }
410    }
411
412    #[test]
413    fn auto_search_never_selects_cloud_just_because_a_key_exists() {
414        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
415        let prior = std::env::var("OLLAMA_API_KEY").ok();
416        unsafe {
417            std::env::set_var("OLLAMA_API_KEY", "test-key-must-not-route");
418        }
419        let cfg = mermaid_domain::Config::default();
420        let capabilities = web::WebCapabilities::resolve(&cfg.web);
421        assert_eq!(capabilities.search.backend, "managed_searxng");
422        assert_eq!(
423            capabilities.search.available,
424            crate::searxng::managed_backend_viability().is_ok()
425        );
426        unsafe {
427            match prior {
428                Some(value) => std::env::set_var("OLLAMA_API_KEY", value),
429                None => std::env::remove_var("OLLAMA_API_KEY"),
430            }
431        }
432    }
433
434    #[test]
435    fn auto_search_fallback_engages_only_when_opted_in_with_a_key() {
436        // The opt-in flips exactly one case: auto + no viable bundle + key.
437        // A viable sovereign default always wins over the fallback, and the
438        // not-opted-in side is pinned by
439        // `auto_search_never_selects_cloud_just_because_a_key_exists`.
440        let _guard = ENV_LOCK
441            .lock()
442            .unwrap_or_else(std::sync::PoisonError::into_inner);
443        let prior = std::env::var("OLLAMA_API_KEY").ok();
444        unsafe {
445            std::env::set_var("OLLAMA_API_KEY", "test-key-fallback");
446        }
447        let mut cfg = mermaid_domain::Config::default();
448        cfg.web.allow_ollama_search_fallback = true;
449        let capabilities = web::WebCapabilities::resolve(&cfg.web);
450        if crate::searxng::managed_backend_viability().is_ok() {
451            assert_eq!(capabilities.search.backend, "managed_searxng");
452            assert!(capabilities.search.available);
453        } else {
454            assert_eq!(capabilities.search.backend, "ollama_cloud");
455            assert!(capabilities.search.available);
456            assert_eq!(capabilities.search.egress, web::Egress::OffMachine);
457            assert!(
458                capabilities.search_tool().is_some(),
459                "the fallback must produce a registrable tool"
460            );
461        }
462        unsafe {
463            match prior {
464                Some(v) => std::env::set_var("OLLAMA_API_KEY", v),
465                None => std::env::remove_var("OLLAMA_API_KEY"),
466            }
467        }
468    }
469
470    #[test]
471    fn auto_search_fallback_without_a_key_reports_the_whole_chain() {
472        let _guard = ENV_LOCK
473            .lock()
474            .unwrap_or_else(std::sync::PoisonError::into_inner);
475        let prior = std::env::var("OLLAMA_API_KEY").ok();
476        unsafe {
477            std::env::remove_var("OLLAMA_API_KEY");
478        }
479        let mut cfg = mermaid_domain::Config::default();
480        cfg.web.allow_ollama_search_fallback = true;
481        let capabilities = web::WebCapabilities::resolve(&cfg.web);
482        if crate::searxng::managed_backend_viability().is_err() {
483            assert!(!capabilities.search.available);
484            assert_eq!(capabilities.search.backend, "ollama_cloud");
485            let reason = capabilities.search.reason.as_deref().unwrap_or_default();
486            assert!(reason.contains("managed bundle"), "{reason}");
487            assert!(reason.contains("OLLAMA_API_KEY"), "{reason}");
488        }
489        unsafe {
490            if let Some(v) = prior {
491                std::env::set_var("OLLAMA_API_KEY", v);
492            }
493        }
494    }
495
496    #[test]
497    fn build_registers_searxng_web_search_without_key() {
498        // The SearXNG search backend registers regardless of OLLAMA_API_KEY —
499        // reachability is a call-time concern, not a registration one.
500        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
501        let prior = std::env::var("OLLAMA_API_KEY").ok();
502        unsafe {
503            std::env::remove_var("OLLAMA_API_KEY");
504        }
505        let mut cfg = mermaid_domain::Config::default();
506        cfg.web.search_backend = mermaid_domain::SearchBackend::Searxng;
507        let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
508        let r = ToolRegistry::build(&cfg, providers);
509        assert!(
510            r.get("web_search").is_some(),
511            "searxng web_search registers without a key"
512        );
513        assert!(
514            r.get("web_fetch").is_some(),
515            "native web_fetch still present"
516        );
517        unsafe {
518            if let Some(v) = prior {
519                std::env::set_var("OLLAMA_API_KEY", v);
520            }
521        }
522    }
523
524    #[test]
525    fn network_deny_omits_all_web_capabilities() {
526        let mut cfg = mermaid_domain::Config::default();
527        cfg.safety.network = mermaid_domain::NetworkPolicy::Deny;
528        cfg.web.search_backend = mermaid_domain::SearchBackend::Searxng;
529        let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
530        let registry = ToolRegistry::build(&cfg, providers);
531        assert!(registry.get("web_fetch").is_none());
532        assert!(registry.get("web_search").is_none());
533        assert!(registry.get("read_file").is_some());
534        // Calling an omitted web tool is answered with the cause, not a bare
535        // "unknown tool" — that reply was observed driving models to guess.
536        for tool in ["web_fetch", "web_search"] {
537            let outcome = registry.unknown_tool_outcome(tool, tool);
538            let msg = outcome.error_message().unwrap_or_default();
539            assert!(msg.contains("safety.network"), "{tool}: {msg}");
540        }
541        // Matched pair: a registered tool carries no absence note, and a name
542        // never considered stays a plain unknown tool.
543        assert!(registry.unavailable_reason("read_file").is_none());
544        let outcome = registry.unknown_tool_outcome("frobnicate", "frobnicate");
545        assert_eq!(
546            outcome.error_message().unwrap_or_default(),
547            "unknown tool: frobnicate"
548        );
549    }
550
551    #[test]
552    fn unavailable_search_backend_reason_reaches_the_model() {
553        // The Windows field logs: `search_backend = "auto"` with no viable
554        // managed bundle registered nothing, and calling `web_search` got
555        // "unknown tool". The registry must instead carry the viability
556        // reason plus the remediation. On hosts where the managed bundle IS
557        // viable, the tool registers and no note exists — both sides pinned.
558        let _guard = ENV_LOCK
559            .lock()
560            .unwrap_or_else(std::sync::PoisonError::into_inner);
561        let prior = std::env::var("OLLAMA_API_KEY").ok();
562        unsafe {
563            std::env::remove_var("OLLAMA_API_KEY");
564        }
565        let cfg = mermaid_domain::Config::default();
566        let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
567        let registry = ToolRegistry::build(&cfg, providers);
568        match crate::searxng::managed_backend_viability() {
569            Ok(_) => {
570                assert!(registry.get("web_search").is_some());
571                assert!(registry.unavailable_reason("web_search").is_none());
572            },
573            Err(viability_reason) => {
574                assert!(registry.get("web_search").is_none());
575                let reason = registry
576                    .unavailable_reason("web_search")
577                    .expect("absence reason recorded");
578                assert!(
579                    reason.contains(&viability_reason),
580                    "must carry the real cause: {reason}"
581                );
582                assert!(
583                    reason.contains("search_backend"),
584                    "must carry the remediation: {reason}"
585                );
586            },
587        }
588        unsafe {
589            if let Some(v) = prior {
590                std::env::set_var("OLLAMA_API_KEY", v);
591            }
592        }
593    }
594}