Skip to main content

lean_ctx/core/
tool_profiles.rs

1use std::fmt;
2
3/// Controls which MCP tools are exposed to agents.
4///
5/// Three built-in tiers reduce tool-list overwhelm for new users
6/// while letting power users keep everything.
7///
8/// When NO profile is pinned (no config key, no env var), the server
9/// advertises only the lazy core set (`CORE_TOOL_NAMES`) and the
10/// effective profile falls back to `Power` — which acts as a pure call-gate
11/// ("everything reachable via ctx_call"), not as an advertisement list.
12/// Pinning a profile makes the advertised set explicit and authoritative
13/// (#358), which costs schema tokens: `standard` advertises its 16 full
14/// schemas, `power` the whole registry (#575).
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum ToolProfile {
17    Minimal,
18    Standard,
19    Power,
20    Custom(Vec<String>),
21}
22
23impl ToolProfile {
24    pub fn parse(s: &str) -> Option<Self> {
25        match s.to_lowercase().as_str() {
26            "minimal" | "min" => Some(Self::Minimal),
27            "standard" | "std" | "default" => Some(Self::Standard),
28            "power" | "full" | "all" => Some(Self::Power),
29            _ => None,
30        }
31    }
32
33    pub fn as_str(&self) -> &str {
34        match self {
35            Self::Minimal => "minimal",
36            Self::Standard => "standard",
37            Self::Power => "power",
38            Self::Custom(_) => "custom",
39        }
40    }
41
42    pub fn description(&self) -> &str {
43        match self {
44            Self::Minimal => "5 surgical tools — each irreplaceable (recommended)",
45            Self::Standard => "16 balanced tools (adds compose, explore, callgraph, patch, more)",
46            Self::Power => "All tools exposed",
47            Self::Custom(v) => {
48                if v.is_empty() {
49                    "Custom tool list (empty)"
50                } else {
51                    "Custom tool list"
52                }
53            }
54        }
55    }
56
57    /// Returns a profile that behaves identically except `tool_name` reports as
58    /// disabled. Used to align rules-injection with the effective tool visibility
59    /// for native-editor clients that hide specific tools (#1008).
60    #[must_use]
61    pub fn without_tool(&self, tool_name: &str) -> Self {
62        if !self.is_tool_enabled(tool_name) {
63            return self.clone();
64        }
65        match self {
66            Self::Power => Self::Custom(
67                crate::tool_defs::CORE_TOOL_NAMES
68                    .iter()
69                    .chain(STANDARD_TOOLS.iter())
70                    .copied()
71                    .filter(|t| *t != tool_name)
72                    .collect::<std::collections::BTreeSet<_>>()
73                    .into_iter()
74                    .map(String::from)
75                    .collect(),
76            ),
77            Self::Standard => Self::Custom(
78                STANDARD_TOOLS
79                    .iter()
80                    .filter(|t| **t != tool_name)
81                    .map(|t| String::from(*t))
82                    .collect(),
83            ),
84            Self::Minimal => Self::Custom(
85                MINIMAL_TOOLS
86                    .iter()
87                    .filter(|t| **t != tool_name)
88                    .map(|t| String::from(*t))
89                    .collect(),
90            ),
91            Self::Custom(list) => Self::Custom(
92                list.iter()
93                    .filter(|t| t.as_str() != tool_name)
94                    .cloned()
95                    .collect(),
96            ),
97        }
98    }
99
100    pub fn is_tool_enabled(&self, tool_name: &str) -> bool {
101        match self {
102            Self::Power => true,
103            Self::Minimal => MINIMAL_TOOLS.contains(&tool_name),
104            Self::Standard => STANDARD_TOOLS.contains(&tool_name),
105            Self::Custom(list) => list.iter().any(|t| t == tool_name),
106        }
107    }
108
109    pub fn tool_count(&self) -> usize {
110        match self {
111            Self::Minimal => MINIMAL_TOOLS.len(),
112            Self::Standard => STANDARD_TOOLS.len(),
113            Self::Power => 0, // dynamic — caller should use registry count
114            Self::Custom(list) => list.len(),
115        }
116    }
117
118    pub fn tool_names(&self) -> Vec<&str> {
119        match self {
120            Self::Minimal => MINIMAL_TOOLS.to_vec(),
121            Self::Standard => STANDARD_TOOLS.to_vec(),
122            Self::Power | Self::Custom(_) => vec![],
123        }
124    }
125
126    /// Resolves the active tool profile from environment, then config.
127    ///
128    /// Priority: `LEAN_CTX_TOOL_PROFILE` env > config `tool_profile` > config `tools.enabled` > default.
129    /// Existing installs default to `power` (backward compat).
130    /// New installs set `standard` during setup.
131    pub fn from_config(cfg: &super::config::Config) -> Self {
132        if let Ok(val) = std::env::var("LEAN_CTX_TOOL_PROFILE") {
133            let trimmed = val.trim();
134            if let Some(profile) = Self::parse(trimmed) {
135                return profile;
136            }
137            // Same "unpin" sentinel handling as for the config key below (#431).
138            if !trimmed.is_empty() && !is_unpinned_alias(trimmed) {
139                tracing::warn!("Unknown LEAN_CTX_TOOL_PROFILE value '{trimmed}', using config");
140            }
141        }
142
143        if let Some(ref profile_name) = cfg.tool_profile {
144            if let Some(profile) = Self::parse(profile_name) {
145                return profile;
146            }
147            // `lean`/`lazy`/`reset` are the *unpinned* sentinel (lazy core
148            // advertised, everything reachable via ctx_call) — not a pinned
149            // tier. They can legitimately land in config (older versions, the
150            // dashboard's "Lean" button, manual edits), so resolve them
151            // silently to the default instead of warning + falling back (#431).
152            if !is_unpinned_alias(profile_name) {
153                tracing::warn!("Unknown tool_profile '{profile_name}' in config, using default");
154            }
155        }
156
157        if !cfg.tools_enabled.is_empty() {
158            return Self::Custom(cfg.tools_enabled.clone());
159        }
160
161        Self::Power
162    }
163}
164
165impl fmt::Display for ToolProfile {
166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167        write!(f, "{}", self.as_str())
168    }
169}
170
171/// `""`/`lean`/`lazy`/`reset` are not pinned tiers — they are the *unpin*
172/// sentinel that clears any pin so the default returns (lazy core advertised,
173/// everything callable via `ctx_call`). Empty/whitespace is the documented
174/// default of `tool_profile` (#613); treating it as unpinned avoids a spurious
175/// `Unknown tool_profile ''` warning on the documented default. Centralised so
176/// the config loader, the CLI (`lean-ctx profile lean`) and the dashboard all
177/// agree on the same set (#431).
178pub fn is_unpinned_alias(name: &str) -> bool {
179    matches!(
180        name.trim().to_ascii_lowercase().as_str(),
181        "" | "lean" | "lazy" | "reset"
182    )
183}
184
185/// Surgical core — each tool is irreplaceable. Agent learns <10 tools,
186/// reliably picks the right one for every intent.
187///
188/// #509: symbol lookup folded into `ctx_search` (action="symbol"), so the
189/// former `ctx_symbol` entry is gone — one search entry, not two.
190const MINIMAL_TOOLS: &[&str] = &[
191    "ctx_read",
192    "ctx_shell",
193    "ctx_search",
194    "ctx_glob",
195    "ctx_tree",
196];
197
198/// Balanced set. Adds power-user tools the agent picks regularly but
199/// are not essential for every session.
200///
201/// #509: `ctx_semantic_search` and `ctx_symbol` are folded into `ctx_search`
202/// (action="semantic"/"symbol") and dropped from the advertised set.
203///
204/// #1008: `ctx_patch` (hash-anchored editing) joins Standard — a pinned profile
205/// is the user's explicit, client-agnostic choice, so the anchored editor is
206/// always part of it. `ctx_edit` (str_replace) stays power-only.
207const STANDARD_TOOLS: &[&str] = &[
208    "ctx_read",
209    "ctx_shell",
210    "ctx_search",
211    "ctx_glob",
212    "ctx_tree",
213    "ctx_compose",
214    "ctx_explore",
215    "ctx_knowledge",
216    "ctx_session",
217    "ctx_callgraph",
218    "ctx_graph",
219    "ctx_delta",
220    "ctx_execute",
221    "ctx_expand",
222    "ctx_overview",
223    "ctx_url_read",
224    "ctx_patch",
225];
226
227/// Available built-in profile names.
228pub const PROFILE_NAMES: &[&str] = &["minimal", "standard", "power"];
229
230pub struct ProfileInfo {
231    pub name: &'static str,
232    pub tool_count: &'static str,
233    pub description: &'static str,
234}
235
236pub fn list_profiles() -> Vec<ProfileInfo> {
237    vec![
238        ProfileInfo {
239            name: "minimal",
240            tool_count: "5",
241            description: "Surgical core — each tool irreplaceable (recommended)",
242        },
243        ProfileInfo {
244            name: "standard",
245            tool_count: "17",
246            description: "Balanced set — adds compose, knowledge, session, callgraph, patch, more",
247        },
248        ProfileInfo {
249            name: "power",
250            tool_count: "all",
251            description: "Every tool exposed (backward compatible)",
252        },
253    ]
254}
255
256/// Writes the `tool_profile` setting to config.toml, preserving all comments,
257/// formatting, and unrelated keys (robust against substring/comment matches).
258pub fn set_profile_in_config(profile_name: &str) -> Result<(), String> {
259    // Canonical config location (RO-safe config category, GH #408). Writing it
260    // anywhere else than `Config::load` reads would split-brain once the data
261    // default flips to `$XDG_DATA_HOME`.
262    let config_path = crate::core::config::Config::path()
263        .ok_or_else(|| "Cannot determine config dir".to_string())?;
264
265    let mut doc = crate::config_io::load_toml_document(&config_path);
266    doc["tool_profile"] = toml_edit::value(profile_name);
267    crate::config_io::write_toml_document(&config_path, &doc)?;
268    Ok(())
269}
270
271/// Removes the `tool_profile` key from config.toml, restoring the lean
272/// default: only the lazy core set is advertised in `tools/list`,
273/// while every registered tool stays reachable through `ctx_call`. This is
274/// the recommended low-overhead mode (#575).
275pub fn clear_profile_in_config() -> Result<(), String> {
276    let config_path = crate::core::config::Config::path()
277        .ok_or_else(|| "Cannot determine config dir".to_string())?;
278    if !config_path.exists() {
279        return Ok(());
280    }
281
282    let mut doc = crate::config_io::load_toml_document(&config_path);
283    if doc.remove("tool_profile").is_none() {
284        return Ok(());
285    }
286    crate::config_io::write_toml_document(&config_path, &doc)?;
287    Ok(())
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn parse_known_profiles() {
296        assert_eq!(ToolProfile::parse("minimal"), Some(ToolProfile::Minimal));
297        assert_eq!(ToolProfile::parse("min"), Some(ToolProfile::Minimal));
298        assert_eq!(ToolProfile::parse("standard"), Some(ToolProfile::Standard));
299        assert_eq!(ToolProfile::parse("std"), Some(ToolProfile::Standard));
300        assert_eq!(ToolProfile::parse("default"), Some(ToolProfile::Standard));
301        assert_eq!(ToolProfile::parse("power"), Some(ToolProfile::Power));
302        assert_eq!(ToolProfile::parse("full"), Some(ToolProfile::Power));
303        assert_eq!(ToolProfile::parse("all"), Some(ToolProfile::Power));
304    }
305
306    #[test]
307    fn parse_case_insensitive() {
308        assert_eq!(ToolProfile::parse("MINIMAL"), Some(ToolProfile::Minimal));
309        assert_eq!(ToolProfile::parse("Standard"), Some(ToolProfile::Standard));
310        assert_eq!(ToolProfile::parse("POWER"), Some(ToolProfile::Power));
311    }
312
313    #[test]
314    fn parse_unknown_returns_none() {
315        assert_eq!(ToolProfile::parse("unknown"), None);
316        assert_eq!(ToolProfile::parse(""), None);
317    }
318
319    #[test]
320    fn minimal_profile_schema_budget() {
321        // tool_profile=minimal advertises the 9-tool surgical set; the schemas
322        // they re-send every turn (description + input schema) must stay small.
323        // This is the tool-side of the faithful-arm per-turn prefix tax (#361).
324        const MINIMAL_SCHEMA_BUDGET_TOKENS: usize = 2800;
325        let defs = crate::server::registry::build_registry().tool_defs();
326        let total: usize = defs
327            .iter()
328            .filter(|t| MINIMAL_TOOLS.contains(&t.name.as_ref()))
329            .map(crate::core::context_overhead::tool_tokens)
330            .sum();
331        assert!(total > 0, "minimal tools must exist in the registry");
332        assert!(
333            total <= MINIMAL_SCHEMA_BUDGET_TOKENS,
334            "minimal-profile tool schemas = {total} tok, budget {MINIMAL_SCHEMA_BUDGET_TOKENS}"
335        );
336    }
337
338    #[test]
339    fn minimal_is_subset_of_standard() {
340        for tool in MINIMAL_TOOLS {
341            assert!(
342                STANDARD_TOOLS.contains(tool),
343                "minimal tool {tool} missing from standard"
344            );
345        }
346    }
347
348    #[test]
349    fn power_enables_everything() {
350        let profile = ToolProfile::Power;
351        assert!(profile.is_tool_enabled("ctx_read"));
352        assert!(profile.is_tool_enabled("ctx_anything"));
353        assert!(profile.is_tool_enabled("nonexistent_tool"));
354    }
355
356    #[test]
357    fn minimal_filters_correctly() {
358        let profile = ToolProfile::Minimal;
359        assert!(profile.is_tool_enabled("ctx_read"));
360        assert!(profile.is_tool_enabled("ctx_shell"));
361        assert!(profile.is_tool_enabled("ctx_search"));
362        assert!(profile.is_tool_enabled("ctx_glob"));
363        assert!(profile.is_tool_enabled("ctx_tree"));
364        // #509: symbol/semantic lookups are now ctx_search actions, not their
365        // own minimal tools.
366        assert!(!profile.is_tool_enabled("ctx_symbol"));
367        assert!(!profile.is_tool_enabled("ctx_semantic_search"));
368        assert!(!profile.is_tool_enabled("ctx_callgraph"));
369        assert!(!profile.is_tool_enabled("ctx_benchmark"));
370    }
371
372    #[test]
373    fn standard_filters_correctly() {
374        let profile = ToolProfile::Standard;
375        assert!(profile.is_tool_enabled("ctx_read"));
376        assert!(profile.is_tool_enabled("ctx_compose"));
377        assert!(profile.is_tool_enabled("ctx_explore"));
378        assert!(profile.is_tool_enabled("ctx_glob"));
379        assert!(profile.is_tool_enabled("ctx_callgraph"));
380        assert!(profile.is_tool_enabled("ctx_graph"));
381        assert!(profile.is_tool_enabled("ctx_session"));
382        assert!(profile.is_tool_enabled("ctx_delta"));
383        assert!(profile.is_tool_enabled("ctx_expand"));
384        assert!(profile.is_tool_enabled("ctx_execute"));
385        assert!(profile.is_tool_enabled("ctx_overview"));
386        // #509: ctx_symbol + ctx_semantic_search folded into ctx_search (action=…),
387        // ctx_multi_read into ctx_read (paths=…) — all dropped from Standard.
388        assert!(!profile.is_tool_enabled("ctx_symbol"));
389        assert!(!profile.is_tool_enabled("ctx_semantic_search"));
390        assert!(!profile.is_tool_enabled("ctx_multi_read"));
391        assert!(profile.is_tool_enabled("ctx_url_read"));
392        // #1008: anchored editing is part of the pinned Standard surface.
393        assert!(profile.is_tool_enabled("ctx_patch"));
394        assert!(!profile.is_tool_enabled("ctx_benchmark"));
395        assert!(!profile.is_tool_enabled("ctx_analyze"));
396        assert!(!profile.is_tool_enabled("ctx_refactor"));
397        assert!(!profile.is_tool_enabled("ctx_edit"));
398    }
399
400    #[test]
401    fn custom_profile_uses_provided_list() {
402        let profile = ToolProfile::Custom(vec!["ctx_read".to_string(), "ctx_shell".to_string()]);
403        assert!(profile.is_tool_enabled("ctx_read"));
404        assert!(profile.is_tool_enabled("ctx_shell"));
405        assert!(!profile.is_tool_enabled("ctx_search"));
406    }
407
408    #[test]
409    fn profile_display_counts_match_tool_arrays() {
410        // The numbers shown by `lean-ctx tools` must equal the actual array
411        // lengths, so adding/removing a profile tool (e.g. the `shell` alias)
412        // can never silently desync the advertised count from reality.
413        let profiles = list_profiles();
414        assert_eq!(
415            profiles[0].tool_count.parse::<usize>().unwrap(),
416            MINIMAL_TOOLS.len(),
417            "minimal count must match MINIMAL_TOOLS length",
418        );
419        assert_eq!(
420            profiles[1].tool_count.parse::<usize>().unwrap(),
421            STANDARD_TOOLS.len(),
422            "standard count must match STANDARD_TOOLS length",
423        );
424        assert_eq!(profiles[2].tool_count, "all");
425    }
426
427    #[test]
428    fn custom_empty_enables_nothing() {
429        let profile = ToolProfile::Custom(vec![]);
430        assert!(!profile.is_tool_enabled("ctx_read"));
431    }
432
433    #[test]
434    fn display_matches_as_str() {
435        assert_eq!(format!("{}", ToolProfile::Minimal), "minimal");
436        assert_eq!(format!("{}", ToolProfile::Standard), "standard");
437        assert_eq!(format!("{}", ToolProfile::Power), "power");
438        assert_eq!(
439            format!("{}", ToolProfile::Custom(vec!["ctx_read".into()])),
440            "custom"
441        );
442    }
443
444    #[test]
445    fn tool_count_matches_list_length() {
446        assert_eq!(ToolProfile::Minimal.tool_count(), MINIMAL_TOOLS.len());
447        assert_eq!(ToolProfile::Standard.tool_count(), STANDARD_TOOLS.len());
448        assert_eq!(ToolProfile::Power.tool_count(), 0);
449    }
450
451    #[test]
452    fn from_config_defaults_to_power_for_backward_compat() {
453        if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
454            return;
455        }
456        let cfg = crate::core::config::Config {
457            tool_profile: None,
458            tools_enabled: vec![],
459            ..Default::default()
460        };
461        assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Power);
462    }
463
464    #[test]
465    fn from_config_respects_tool_profile_field() {
466        if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
467            return;
468        }
469        let cfg = crate::core::config::Config {
470            tool_profile: Some("minimal".to_string()),
471            tools_enabled: vec![],
472            ..Default::default()
473        };
474        assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Minimal);
475    }
476
477    #[test]
478    fn from_config_tools_enabled_creates_custom() {
479        if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
480            return;
481        }
482        let cfg = crate::core::config::Config {
483            tool_profile: None,
484            tools_enabled: vec!["ctx_read".to_string(), "ctx_shell".to_string()],
485            ..Default::default()
486        };
487        let profile = ToolProfile::from_config(&cfg);
488        assert_eq!(
489            profile,
490            ToolProfile::Custom(vec!["ctx_read".to_string(), "ctx_shell".to_string()])
491        );
492    }
493
494    #[test]
495    fn tool_profile_takes_precedence_over_tools_enabled() {
496        if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
497            return;
498        }
499        let cfg = crate::core::config::Config {
500            tool_profile: Some("standard".to_string()),
501            tools_enabled: vec!["ctx_read".to_string()],
502            ..Default::default()
503        };
504        assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Standard);
505    }
506
507    #[test]
508    fn empty_tool_profile_is_unpinned_so_tools_enabled_applies() {
509        // #613: `tool_profile = ""` is the documented default ("unpin"), not a
510        // bogus profile. It must resolve silently via the unpinned path (so an
511        // explicit `tools_enabled` takes effect) instead of warning
512        // "Unknown tool_profile ''".
513        assert!(is_unpinned_alias(""));
514        assert!(is_unpinned_alias("   "));
515
516        if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
517            return;
518        }
519        let cfg = crate::core::config::Config {
520            tool_profile: Some(String::new()),
521            tools_enabled: vec!["ctx_read".to_string()],
522            ..Default::default()
523        };
524        assert_eq!(
525            ToolProfile::from_config(&cfg),
526            ToolProfile::Custom(vec!["ctx_read".to_string()]),
527            "empty tool_profile must unpin so tools_enabled takes effect"
528        );
529    }
530
531    #[test]
532    fn all_profile_names_are_parseable() {
533        for name in PROFILE_NAMES {
534            assert!(
535                ToolProfile::parse(name).is_some(),
536                "profile name '{name}' should be parseable"
537            );
538        }
539    }
540
541    #[test]
542    fn list_profiles_returns_three_entries() {
543        let profiles = list_profiles();
544        assert_eq!(profiles.len(), 3);
545    }
546
547    #[test]
548    fn standard_includes_all_core_tools() {
549        let profile = ToolProfile::Standard;
550        assert!(
551            profile.is_tool_enabled("ctx_graph"),
552            "ctx_graph must be in standard"
553        );
554        assert!(
555            profile.is_tool_enabled("ctx_delta"),
556            "ctx_delta must be in standard"
557        );
558        assert!(
559            profile.is_tool_enabled("ctx_expand"),
560            "ctx_expand must be in standard"
561        );
562        assert!(
563            profile.is_tool_enabled("ctx_execute"),
564            "ctx_execute must be in standard (sandboxed code execution)"
565        );
566        // ctx_edit is power-only — for editing, Standard carries the anchored
567        // ctx_patch instead (#1008); str_replace stays a power fallback.
568        assert!(
569            !profile.is_tool_enabled("ctx_edit"),
570            "ctx_edit must NOT be in standard (ctx_patch is the standard editor)"
571        );
572        assert!(
573            profile.is_tool_enabled("ctx_patch"),
574            "ctx_patch must be in standard (#1008 anchored editing)"
575        );
576    }
577
578    #[test]
579    fn standard_includes_url_read() {
580        let profile = ToolProfile::Standard;
581        assert!(
582            profile.is_tool_enabled("ctx_url_read"),
583            "ctx_url_read must be in standard (web/research context)"
584        );
585    }
586
587    #[test]
588    fn clear_profile_removes_key_and_is_idempotent() {
589        let iso = crate::core::data_dir::isolated_data_dir();
590        set_profile_in_config("power").unwrap();
591        let config_path = iso.path().join("config.toml");
592        assert!(
593            std::fs::read_to_string(&config_path)
594                .unwrap()
595                .contains("tool_profile"),
596            "set_profile_in_config must write the key"
597        );
598
599        clear_profile_in_config().unwrap();
600        assert!(
601            !std::fs::read_to_string(&config_path)
602                .unwrap()
603                .contains("tool_profile"),
604            "clear_profile_in_config must remove the key (lean default, #575)"
605        );
606
607        // Idempotent: clearing again (and on a missing file) must not fail.
608        clear_profile_in_config().unwrap();
609    }
610
611    #[test]
612    fn clear_profile_on_missing_config_is_ok() {
613        let _iso = crate::core::data_dir::isolated_data_dir();
614        clear_profile_in_config().unwrap();
615    }
616
617    #[test]
618    fn without_tool_removes_from_power() {
619        let filtered = ToolProfile::Power.without_tool("ctx_patch");
620        assert!(!filtered.is_tool_enabled("ctx_patch"));
621        assert!(filtered.is_tool_enabled("ctx_read"));
622        assert!(filtered.is_tool_enabled("ctx_search"));
623    }
624
625    #[test]
626    fn without_tool_removes_from_standard() {
627        let filtered = ToolProfile::Standard.without_tool("ctx_patch");
628        assert!(!filtered.is_tool_enabled("ctx_patch"));
629        assert!(filtered.is_tool_enabled("ctx_read"));
630        assert!(filtered.is_tool_enabled("ctx_compose"));
631    }
632
633    #[test]
634    fn without_tool_noop_for_already_missing() {
635        let filtered = ToolProfile::Minimal.without_tool("ctx_patch");
636        assert!(!filtered.is_tool_enabled("ctx_patch"));
637        assert!(filtered.is_tool_enabled("ctx_read"));
638    }
639}