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;
34pub mod workspace;
35
36use async_trait::async_trait;
37use std::collections::HashMap;
38use std::sync::Arc;
39
40use mermaid_domain::{ToolDefinition, ToolOutcome};
41
42use super::ctx::ExecContext;
43
44/// Implemented by every tool that the model can call. All tools are
45/// `Send + Sync` — they run across tokio `select!` branches inside
46/// the effect runner.
47#[async_trait]
48pub trait ToolExecutor: Send + Sync {
49    /// Canonical name the model uses to call this tool. Matches
50    /// `schema().name` exactly.
51    fn name(&self) -> &'static str;
52
53    /// JSON-schema description the model sees in the outgoing
54    /// request. Adapters translate this into provider-native shape
55    /// (Anthropic's `type: "custom"`, Gemini's `function_declarations`,
56    /// OpenAI's flat `tools`, Ollama's function calling). The same
57    /// `ToolDefinition` feeds all four.
58    fn schema(&self) -> ToolDefinition;
59
60    /// True for tools that exist for internal dispatch only and
61    /// should NOT be advertised to the model (e.g. the MCP proxy
62    /// router, which fronts every `mcp__server__tool` call — the
63    /// individual MCP tools are advertised separately from
64    /// `state.mcp.servers`). Default `false`.
65    fn is_internal(&self) -> bool {
66        false
67    }
68
69    /// Run the tool. The returned `ToolOutcome` is passed verbatim
70    /// into `Msg::ToolFinished` — there's no error-to-outcome
71    /// conversion happening outside this function.
72    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome;
73}
74
75/// Registry of dispatchable tools. Single source of truth for what
76/// the model sees AND what handles a call when the model issues it.
77/// Built once at startup; read-only after that.
78pub struct ToolRegistry {
79    entries: HashMap<&'static str, Arc<dyn ToolExecutor>>,
80    /// Teaching errors for tools that were CONSIDERED at build time and
81    /// deliberately not registered (backend unavailable, network denied,
82    /// filtered out of a child registry). Dispatch returns the reason when
83    /// the model calls one, so the model learns why the tool is absent and
84    /// what to do about it — a bare "unknown tool" reads as a schema bug
85    /// and was observed driving models to fabricate results instead of
86    /// reporting the gap.
87    unavailable: HashMap<&'static str, String>,
88    /// Startup-resolved web routing/viability shared by the parent registry,
89    /// its provider-facing definitions, UI diagnostics, and every child
90    /// registry. Keeping the backend clients here prevents credentials or
91    /// environment changes from silently re-resolving a different route.
92    web_capabilities: Option<Arc<web::WebCapabilities>>,
93    /// Direct handle to the subagent spawner (also reachable through the
94    /// `agent` tool entry, but `dyn ToolExecutor` can't be downcast). The
95    /// effect layer uses it to service `Cmd::KillBackgroundAgent`. `None`
96    /// in registries built without a spawner (child registries, tests).
97    subagent_spawner: Option<Arc<subagent::SubagentSpawner>>,
98}
99
100impl ToolRegistry {
101    #[must_use]
102    pub fn new() -> Self {
103        Self {
104            entries: HashMap::new(),
105            unavailable: HashMap::new(),
106            web_capabilities: None,
107            subagent_spawner: None,
108        }
109    }
110
111    #[must_use]
112    pub fn web_capabilities(&self) -> Option<&web::WebCapabilities> {
113        self.web_capabilities.as_deref()
114    }
115
116    #[must_use]
117    pub fn subagent_spawner(&self) -> Option<&Arc<subagent::SubagentSpawner>> {
118        self.subagent_spawner.as_ref()
119    }
120
121    pub fn register(&mut self, tool: Arc<dyn ToolExecutor>) {
122        self.entries.insert(tool.name(), tool);
123    }
124
125    /// Record why a tool that could exist in this registry deliberately does
126    /// not. The reason is model-facing: it must name the cause and the
127    /// remediation, because it is returned verbatim when the model calls the
128    /// absent tool.
129    pub fn note_unavailable(&mut self, tool: &'static str, reason: impl Into<String>) {
130        self.unavailable.insert(tool, reason.into());
131    }
132
133    #[must_use]
134    pub fn unavailable_reason(&self, name: &str) -> Option<&str> {
135        self.unavailable.get(name).map(String::as_str)
136    }
137
138    /// The outcome for a call this registry cannot dispatch: the recorded
139    /// teaching error when the tool was deliberately omitted, else the plain
140    /// unknown-tool error. `called_name` is the name the model used — for
141    /// MCP calls it differs from the internal `mcp_proxy` routing key, and
142    /// the model should see the name it actually wrote.
143    #[must_use]
144    pub fn unknown_tool_outcome(&self, tool_key: &str, called_name: &str) -> ToolOutcome {
145        self.unavailable.get(tool_key).map_or_else(
146            || ToolOutcome::error(format!("unknown tool: {called_name}"), 0.0),
147            |reason| ToolOutcome::error(format!("{called_name} is not available: {reason}"), 0.0),
148        )
149    }
150
151    #[must_use]
152    pub fn get(&self, name: &str) -> Option<Arc<dyn ToolExecutor>> {
153        self.entries.get(name).cloned()
154    }
155
156    #[must_use]
157    pub fn len(&self) -> usize {
158        self.entries.len()
159    }
160
161    #[must_use]
162    pub fn is_empty(&self) -> bool {
163        self.entries.is_empty()
164    }
165
166    pub fn names(&self) -> impl Iterator<Item = &'static str> + '_ {
167        self.entries.keys().copied()
168    }
169
170    /// Emit every user-facing tool's schema, for inclusion in an
171    /// outgoing `ChatRequest.tools`. Effect runner calls this before
172    /// dispatching `Cmd::CallModel` so the model always sees the
173    /// same list the runner can dispatch. Internal routers (the MCP
174    /// proxy) are filtered out.
175    #[must_use]
176    pub fn describe_all(&self) -> Vec<ToolDefinition> {
177        self.entries
178            .values()
179            .filter(|t| !t.is_internal())
180            .map(|t| t.schema())
181            .collect()
182    }
183}
184
185impl Default for ToolRegistry {
186    fn default() -> Self {
187        let mut r = Self::new();
188        r.register(Arc::new(filesystem::ReadFileTool));
189        r.register(Arc::new(filesystem::WriteFileTool));
190        r.register(Arc::new(apply_patch::ApplyPatchTool));
191        r.register(Arc::new(filesystem::DeleteFileTool));
192        r.register(Arc::new(filesystem::CreateDirectoryTool));
193        r.register(Arc::new(exec::ExecuteCommandTool));
194        r.register(Arc::new(memory::MemoryTool));
195        r.register(Arc::new(ask_user_question::AskUserQuestionTool));
196        // Plan-mode tools are internal (never in describe_all): the reducer
197        // advertises each definition only in the mode where it applies.
198        r.register(Arc::new(enter_plan_mode::EnterPlanModeTool));
199        r.register(Arc::new(exit_plan_mode::ExitPlanModeTool));
200        r.register(Arc::new(tasks::TaskCreateTool));
201        r.register(Arc::new(tasks::TaskUpdateTool));
202        r.register(Arc::new(tasks::TaskListTool));
203        // MCP proxy is the dispatcher for every mcp__server__tool
204        // call; it's internal (not advertised) but MUST be registered
205        // so runtime lookups succeed.
206        r.register(Arc::new(mcp::McpToolProxy));
207        r
208    }
209}
210
211/// Whether the host mermaid process is running interactively (TUI)
212/// or headlessly (one-shot `mermaid run <prompt>` / CI). Controls
213/// which tools get registered: headless mode never advertises
214/// GUI / computer-use tools even when a display probes alive, because
215/// a CI job has no user to watch the screenshot.
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217pub enum TuiMode {
218    Interactive,
219    Headless,
220}
221
222impl ToolRegistry {
223    /// Register the computer-use tools the given backend can actually drive.
224    /// Screenshot works on every usable backend; the five input tools need
225    /// pointer/keyboard injection (X11/Wayland); `list_windows` is X11-only.
226    /// Advertising a tool the backend can't drive means the model is told it
227    /// has a capability that `bail!`s at call time (#35).
228    fn register_computer_use_tools(&mut self, backend: computer_use::Backend) {
229        let driver = Arc::new(computer_use::ComputerUseDriver::new(backend));
230        self.register(Arc::new(computer_use::ScreenshotTool::new(driver.clone())));
231        if backend.supports_input_injection() {
232            self.register(Arc::new(computer_use::ClickTool::new(driver.clone())));
233            self.register(Arc::new(computer_use::TypeTextTool::new(driver.clone())));
234            self.register(Arc::new(computer_use::PressKeyTool::new(driver.clone())));
235            self.register(Arc::new(computer_use::ScrollTool::new(driver.clone())));
236            self.register(Arc::new(computer_use::MouseMoveTool::new(driver.clone())));
237        }
238        if backend.supports_window_listing() {
239            self.register(Arc::new(computer_use::ListWindowsTool::new(driver.clone())));
240        }
241    }
242
243    /// Config-aware factory. Always registers filesystem + exec +
244    /// the MCP proxy + the subagent tool. Conditionally registers:
245    ///
246    ///   - Viable `web_fetch` and `web_search` capabilities resolved once by
247    ///     `web::WebCapabilities`. Global network denial omits both.
248    ///   - The computer-use tools the detected backend can drive (see
249    ///     `register_computer_use_tools`) iff `mode == Interactive` AND
250    ///     `computer_use::probe()` returns a usable backend.
251    ///
252    /// `providers` is the shared `ProviderFactory` that the effect
253    /// runner also holds; the `SubagentSpawner` needs it so child
254    /// reducer loops hit the same provider cache.
255    ///
256    /// Returns `Arc<Self>` so the effect runner can share a handle
257    /// across turns without cloning the underlying `HashMap`.
258    pub fn build(
259        config: &mermaid_domain::Config,
260        mode: TuiMode,
261        providers: Arc<crate::providers::ProviderFactory>,
262    ) -> Arc<Self> {
263        let mut r = Self::new();
264        let web_capabilities = Arc::new(web::WebCapabilities::resolve(&config.web));
265        r.register(Arc::new(filesystem::ReadFileTool));
266        r.register(Arc::new(filesystem::WriteFileTool));
267        r.register(Arc::new(apply_patch::ApplyPatchTool));
268        r.register(Arc::new(filesystem::DeleteFileTool));
269        r.register(Arc::new(filesystem::CreateDirectoryTool));
270        r.register(Arc::new(exec::ExecuteCommandTool));
271        r.register(Arc::new(memory::MemoryTool));
272        r.register(Arc::new(ask_user_question::AskUserQuestionTool));
273        r.register(Arc::new(enter_plan_mode::EnterPlanModeTool));
274        r.register(Arc::new(exit_plan_mode::ExitPlanModeTool));
275        r.register(Arc::new(tasks::TaskCreateTool));
276        r.register(Arc::new(tasks::TaskUpdateTool));
277        r.register(Arc::new(tasks::TaskListTool));
278        r.register(Arc::new(mcp::McpToolProxy));
279
280        // `safety.network = "deny"` is a global egress kill-switch, not only
281        // a shell sandbox flag. Omit web capabilities entirely so adapters and
282        // subagents cannot advertise or execute them — and record why, so a
283        // model that calls one anyway is taught the cause instead of shown a
284        // bare "unknown tool".
285        if config.safety.network == mermaid_domain::NetworkPolicy::Allow {
286            match web_capabilities.fetch_tool() {
287                Some(tool) => r.register(Arc::new(tool)),
288                None => r.note_unavailable(
289                    "web_fetch",
290                    web_capabilities.fetch.absence_reason("web_fetch"),
291                ),
292            }
293            match web_capabilities.search_tool() {
294                Some(tool) => r.register(Arc::new(tool)),
295                None => r.note_unavailable(
296                    "web_search",
297                    web_capabilities.search.absence_reason("web_search"),
298                ),
299            }
300        } else {
301            for tool in ["web_fetch", "web_search"] {
302                r.note_unavailable(
303                    tool,
304                    format!(
305                        "{tool} is disabled: network access is off \
306                         (safety.network = \"deny\" / --no-network)"
307                    ),
308                );
309            }
310        }
311
312        // Computer-use tools only register when (a) the process runs
313        // interactively (Headless CI has no user to watch a screenshot)
314        // AND (b) a display backend passes the startup probe. Failed
315        // probe → tools aren't advertised → model can't call them.
316        if mode == TuiMode::Interactive {
317            let backend = computer_use::probe();
318            if backend.is_usable() {
319                r.register_computer_use_tools(backend);
320            }
321        }
322
323        // Subagents: always register. Depth + breadth caps live on
324        // `SubagentSpawner`; the tool itself is harmless when nobody
325        // calls it. Headless runs do register the agent — a CI prompt
326        // may still delegate to subagents for batched work.
327        let spawner = Arc::new(subagent::SubagentSpawner::new(
328            providers,
329            Arc::clone(&web_capabilities),
330        ));
331        r.register(Arc::new(subagent::SubagentTool::new(spawner.clone())));
332        r.subagent_spawner = Some(spawner);
333        r.web_capabilities = Some(web_capabilities);
334
335        Arc::new(r)
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    #[test]
344    fn default_registry_has_builtin_tools() {
345        let r = ToolRegistry::default();
346        for name in &[
347            "read_file",
348            "write_file",
349            "apply_patch",
350            "delete_file",
351            "create_directory",
352            "execute_command",
353            "memory",
354        ] {
355            assert!(r.get(name).is_some(), "missing: {name}");
356        }
357        assert!(r.get("not_a_tool").is_none());
358        assert!(r.len() >= 6);
359    }
360
361    #[test]
362    fn computer_use_registration_is_selective_per_backend() {
363        use computer_use::Backend;
364        let reg = |b: Backend| {
365            let mut r = ToolRegistry::new();
366            r.register_computer_use_tools(b);
367            r
368        };
369
370        // macOS: only screenshot — the input verbs + list_windows bail at
371        // runtime, so advertising them just wastes the model's turns (#35).
372        let mac = reg(Backend::MacOS);
373        assert!(mac.get("screenshot").is_some());
374        for t in [
375            "click",
376            "type_text",
377            "press_key",
378            "scroll",
379            "mouse_move",
380            "list_windows",
381        ] {
382            assert!(mac.get(t).is_none(), "macOS must not advertise {t}");
383        }
384
385        // Wayland: input tools, but no list_windows (no portable enumeration).
386        let way = reg(Backend::Wayland);
387        assert!(way.get("click").is_some());
388        assert!(way.get("list_windows").is_none());
389
390        // X11: all seven.
391        let x11 = reg(Backend::X11);
392        for t in [
393            "screenshot",
394            "click",
395            "type_text",
396            "press_key",
397            "scroll",
398            "mouse_move",
399            "list_windows",
400        ] {
401            assert!(x11.get(t).is_some(), "X11 missing {t}");
402        }
403    }
404
405    #[test]
406    fn describe_all_returns_one_per_user_facing_tool() {
407        let r = ToolRegistry::default();
408        let schemas = r.describe_all();
409        // mcp_proxy is registered but internal — filtered out of
410        // describe_all. So len() includes it but schemas don't.
411        let visible = r
412            .names()
413            .filter(|n| r.get(n).map(|t| !t.is_internal()).unwrap_or(false))
414            .count();
415        assert_eq!(schemas.len(), visible);
416        for schema in &schemas {
417            assert!(
418                r.get(&schema.name).is_some(),
419                "schema for unknown tool: {}",
420                schema.name
421            );
422        }
423    }
424
425    #[test]
426    fn mcp_proxy_is_registered_but_internal() {
427        let r = ToolRegistry::default();
428        let proxy = r.get("mcp_proxy").expect("mcp_proxy registered");
429        assert!(proxy.is_internal());
430        assert!(!r.describe_all().iter().any(|s| s.name == "mcp_proxy"));
431    }
432
433    #[test]
434    fn schema_name_matches_executor_name() {
435        let r = ToolRegistry::default();
436        for name in r.names() {
437            let tool = r.get(name).unwrap();
438            assert_eq!(tool.name(), tool.schema().name.as_str());
439        }
440    }
441
442    /// Serialization guard for tests that mutate the `OLLAMA_API_KEY`
443    /// env var. Cargo's default test harness runs tests in parallel
444    /// threads inside one process; without this mutex two env-touching
445    /// tests would race and occasionally flip each other's expectations.
446    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
447
448    #[test]
449    fn build_registers_zero_config_web_tools_without_key() {
450        // Both web tools register with no OLLAMA_API_KEY: web_fetch is native,
451        // and web_search defaults to `auto`, which falls back to a managed local
452        // SearXNG (the process starts lazily at call time, not here).
453        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
454        let prior = std::env::var("OLLAMA_API_KEY").ok();
455        unsafe {
456            std::env::remove_var("OLLAMA_API_KEY");
457        }
458        let cfg = mermaid_domain::Config::default();
459        let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
460        let r = ToolRegistry::build(&cfg, TuiMode::Headless, providers);
461        assert!(
462            r.get("web_fetch").is_some(),
463            "native web_fetch registers without a key"
464        );
465        assert_eq!(
466            r.get("web_search").is_some(),
467            crate::searxng::managed_backend_viability().is_ok(),
468            "auto web_search registers only when managed SearXNG is viable"
469        );
470        assert!(r.get("read_file").is_some());
471        assert!(r.get("execute_command").is_some());
472        let web = r
473            .web_capabilities()
474            .expect("config-aware registries retain the resolved web status");
475        assert_eq!(web.fetch.backend, "native");
476        assert_eq!(web.search.backend, "managed_searxng");
477        unsafe {
478            if let Some(v) = prior {
479                std::env::set_var("OLLAMA_API_KEY", v);
480            }
481        }
482    }
483
484    #[test]
485    fn build_registers_ollama_web_search_with_key() {
486        // Cloud routing is explicit: a key plus an explicit Ollama backend
487        // registers search without changing the native fetch default.
488        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
489        let prior = std::env::var("OLLAMA_API_KEY").ok();
490        unsafe {
491            std::env::set_var("OLLAMA_API_KEY", "test-key-build");
492        }
493        let mut cfg = mermaid_domain::Config::default();
494        cfg.web.search_backend = mermaid_domain::SearchBackend::Ollama;
495        let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
496        let r = ToolRegistry::build(&cfg, TuiMode::Interactive, providers);
497        assert!(r.get("web_search").is_some(), "web_search registered");
498        assert!(r.get("web_fetch").is_some(), "web_fetch registered");
499        unsafe {
500            match prior {
501                Some(v) => std::env::set_var("OLLAMA_API_KEY", v),
502                None => std::env::remove_var("OLLAMA_API_KEY"),
503            }
504        }
505    }
506
507    #[test]
508    fn auto_search_never_selects_cloud_just_because_a_key_exists() {
509        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
510        let prior = std::env::var("OLLAMA_API_KEY").ok();
511        unsafe {
512            std::env::set_var("OLLAMA_API_KEY", "test-key-must-not-route");
513        }
514        let cfg = mermaid_domain::Config::default();
515        let capabilities = web::WebCapabilities::resolve(&cfg.web);
516        assert_eq!(capabilities.search.backend, "managed_searxng");
517        assert_eq!(
518            capabilities.search.available,
519            crate::searxng::managed_backend_viability().is_ok()
520        );
521        unsafe {
522            match prior {
523                Some(value) => std::env::set_var("OLLAMA_API_KEY", value),
524                None => std::env::remove_var("OLLAMA_API_KEY"),
525            }
526        }
527    }
528
529    #[test]
530    fn auto_search_fallback_engages_only_when_opted_in_with_a_key() {
531        // The opt-in flips exactly one case: auto + no viable bundle + key.
532        // A viable sovereign default always wins over the fallback, and the
533        // not-opted-in side is pinned by
534        // `auto_search_never_selects_cloud_just_because_a_key_exists`.
535        let _guard = ENV_LOCK
536            .lock()
537            .unwrap_or_else(std::sync::PoisonError::into_inner);
538        let prior = std::env::var("OLLAMA_API_KEY").ok();
539        unsafe {
540            std::env::set_var("OLLAMA_API_KEY", "test-key-fallback");
541        }
542        let mut cfg = mermaid_domain::Config::default();
543        cfg.web.allow_ollama_search_fallback = true;
544        let capabilities = web::WebCapabilities::resolve(&cfg.web);
545        if crate::searxng::managed_backend_viability().is_ok() {
546            assert_eq!(capabilities.search.backend, "managed_searxng");
547            assert!(capabilities.search.available);
548        } else {
549            assert_eq!(capabilities.search.backend, "ollama_cloud");
550            assert!(capabilities.search.available);
551            assert_eq!(capabilities.search.egress, web::Egress::OffMachine);
552            assert!(
553                capabilities.search_tool().is_some(),
554                "the fallback must produce a registrable tool"
555            );
556        }
557        unsafe {
558            match prior {
559                Some(v) => std::env::set_var("OLLAMA_API_KEY", v),
560                None => std::env::remove_var("OLLAMA_API_KEY"),
561            }
562        }
563    }
564
565    #[test]
566    fn auto_search_fallback_without_a_key_reports_the_whole_chain() {
567        let _guard = ENV_LOCK
568            .lock()
569            .unwrap_or_else(std::sync::PoisonError::into_inner);
570        let prior = std::env::var("OLLAMA_API_KEY").ok();
571        unsafe {
572            std::env::remove_var("OLLAMA_API_KEY");
573        }
574        let mut cfg = mermaid_domain::Config::default();
575        cfg.web.allow_ollama_search_fallback = true;
576        let capabilities = web::WebCapabilities::resolve(&cfg.web);
577        if crate::searxng::managed_backend_viability().is_err() {
578            assert!(!capabilities.search.available);
579            assert_eq!(capabilities.search.backend, "ollama_cloud");
580            let reason = capabilities.search.reason.as_deref().unwrap_or_default();
581            assert!(reason.contains("managed bundle"), "{reason}");
582            assert!(reason.contains("OLLAMA_API_KEY"), "{reason}");
583        }
584        unsafe {
585            if let Some(v) = prior {
586                std::env::set_var("OLLAMA_API_KEY", v);
587            }
588        }
589    }
590
591    #[test]
592    fn build_registers_searxng_web_search_without_key() {
593        // The SearXNG search backend registers regardless of OLLAMA_API_KEY —
594        // reachability is a call-time concern, not a registration one.
595        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
596        let prior = std::env::var("OLLAMA_API_KEY").ok();
597        unsafe {
598            std::env::remove_var("OLLAMA_API_KEY");
599        }
600        let mut cfg = mermaid_domain::Config::default();
601        cfg.web.search_backend = mermaid_domain::SearchBackend::Searxng;
602        let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
603        let r = ToolRegistry::build(&cfg, TuiMode::Headless, providers);
604        assert!(
605            r.get("web_search").is_some(),
606            "searxng web_search registers without a key"
607        );
608        assert!(
609            r.get("web_fetch").is_some(),
610            "native web_fetch still present"
611        );
612        unsafe {
613            if let Some(v) = prior {
614                std::env::set_var("OLLAMA_API_KEY", v);
615            }
616        }
617    }
618
619    #[test]
620    fn network_deny_omits_all_web_capabilities() {
621        let mut cfg = mermaid_domain::Config::default();
622        cfg.safety.network = mermaid_domain::NetworkPolicy::Deny;
623        cfg.web.search_backend = mermaid_domain::SearchBackend::Searxng;
624        let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
625        let registry = ToolRegistry::build(&cfg, TuiMode::Headless, providers);
626        assert!(registry.get("web_fetch").is_none());
627        assert!(registry.get("web_search").is_none());
628        assert!(registry.get("read_file").is_some());
629        // Calling an omitted web tool is answered with the cause, not a bare
630        // "unknown tool" — that reply was observed driving models to guess.
631        for tool in ["web_fetch", "web_search"] {
632            let outcome = registry.unknown_tool_outcome(tool, tool);
633            let msg = outcome.error_message().unwrap_or_default();
634            assert!(msg.contains("safety.network"), "{tool}: {msg}");
635        }
636        // Matched pair: a registered tool carries no absence note, and a name
637        // never considered stays a plain unknown tool.
638        assert!(registry.unavailable_reason("read_file").is_none());
639        let outcome = registry.unknown_tool_outcome("frobnicate", "frobnicate");
640        assert_eq!(
641            outcome.error_message().unwrap_or_default(),
642            "unknown tool: frobnicate"
643        );
644    }
645
646    #[test]
647    fn unavailable_search_backend_reason_reaches_the_model() {
648        // The Windows field logs: `search_backend = "auto"` with no viable
649        // managed bundle registered nothing, and calling `web_search` got
650        // "unknown tool". The registry must instead carry the viability
651        // reason plus the remediation. On hosts where the managed bundle IS
652        // viable, the tool registers and no note exists — both sides pinned.
653        let _guard = ENV_LOCK
654            .lock()
655            .unwrap_or_else(std::sync::PoisonError::into_inner);
656        let prior = std::env::var("OLLAMA_API_KEY").ok();
657        unsafe {
658            std::env::remove_var("OLLAMA_API_KEY");
659        }
660        let cfg = mermaid_domain::Config::default();
661        let providers = Arc::new(crate::providers::ProviderFactory::new(cfg.clone()));
662        let registry = ToolRegistry::build(&cfg, TuiMode::Headless, providers);
663        match crate::searxng::managed_backend_viability() {
664            Ok(_) => {
665                assert!(registry.get("web_search").is_some());
666                assert!(registry.unavailable_reason("web_search").is_none());
667            },
668            Err(viability_reason) => {
669                assert!(registry.get("web_search").is_none());
670                let reason = registry
671                    .unavailable_reason("web_search")
672                    .expect("absence reason recorded");
673                assert!(
674                    reason.contains(&viability_reason),
675                    "must carry the real cause: {reason}"
676                );
677                assert!(
678                    reason.contains("search_backend"),
679                    "must carry the remediation: {reason}"
680                );
681            },
682        }
683        unsafe {
684            if let Some(v) = prior {
685                std::env::set_var("OLLAMA_API_KEY", v);
686            }
687        }
688    }
689}