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_callgraph",
217    "ctx_graph",
218    "ctx_delta",
219    "ctx_execute",
220    "ctx_expand",
221    "ctx_overview",
222    "ctx_url_read",
223    "ctx_patch",
224];
225
226/// Available built-in profile names.
227pub const PROFILE_NAMES: &[&str] = &["minimal", "standard", "power"];
228
229pub struct ProfileInfo {
230    pub name: &'static str,
231    pub tool_count: &'static str,
232    pub description: &'static str,
233}
234
235pub fn list_profiles() -> Vec<ProfileInfo> {
236    vec![
237        ProfileInfo {
238            name: "minimal",
239            tool_count: "5",
240            description: "Surgical core — each tool irreplaceable (recommended)",
241        },
242        ProfileInfo {
243            name: "standard",
244            tool_count: "16",
245            description: "Balanced set — adds compose, explore, callgraph, patch, execute, more",
246        },
247        ProfileInfo {
248            name: "power",
249            tool_count: "all",
250            description: "Every tool exposed (backward compatible)",
251        },
252    ]
253}
254
255/// Writes the `tool_profile` setting to config.toml, preserving all comments,
256/// formatting, and unrelated keys (robust against substring/comment matches).
257pub fn set_profile_in_config(profile_name: &str) -> Result<(), String> {
258    // Canonical config location (RO-safe config category, GH #408). Writing it
259    // anywhere else than `Config::load` reads would split-brain once the data
260    // default flips to `$XDG_DATA_HOME`.
261    let config_path = crate::core::config::Config::path()
262        .ok_or_else(|| "Cannot determine config dir".to_string())?;
263
264    let mut doc = crate::config_io::load_toml_document(&config_path);
265    doc["tool_profile"] = toml_edit::value(profile_name);
266    crate::config_io::write_toml_document(&config_path, &doc)?;
267    Ok(())
268}
269
270/// Removes the `tool_profile` key from config.toml, restoring the lean
271/// default: only the lazy core set is advertised in `tools/list`,
272/// while every registered tool stays reachable through `ctx_call`. This is
273/// the recommended low-overhead mode (#575).
274pub fn clear_profile_in_config() -> Result<(), String> {
275    let config_path = crate::core::config::Config::path()
276        .ok_or_else(|| "Cannot determine config dir".to_string())?;
277    if !config_path.exists() {
278        return Ok(());
279    }
280
281    let mut doc = crate::config_io::load_toml_document(&config_path);
282    if doc.remove("tool_profile").is_none() {
283        return Ok(());
284    }
285    crate::config_io::write_toml_document(&config_path, &doc)?;
286    Ok(())
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn parse_known_profiles() {
295        assert_eq!(ToolProfile::parse("minimal"), Some(ToolProfile::Minimal));
296        assert_eq!(ToolProfile::parse("min"), Some(ToolProfile::Minimal));
297        assert_eq!(ToolProfile::parse("standard"), Some(ToolProfile::Standard));
298        assert_eq!(ToolProfile::parse("std"), Some(ToolProfile::Standard));
299        assert_eq!(ToolProfile::parse("default"), Some(ToolProfile::Standard));
300        assert_eq!(ToolProfile::parse("power"), Some(ToolProfile::Power));
301        assert_eq!(ToolProfile::parse("full"), Some(ToolProfile::Power));
302        assert_eq!(ToolProfile::parse("all"), Some(ToolProfile::Power));
303    }
304
305    #[test]
306    fn parse_case_insensitive() {
307        assert_eq!(ToolProfile::parse("MINIMAL"), Some(ToolProfile::Minimal));
308        assert_eq!(ToolProfile::parse("Standard"), Some(ToolProfile::Standard));
309        assert_eq!(ToolProfile::parse("POWER"), Some(ToolProfile::Power));
310    }
311
312    #[test]
313    fn parse_unknown_returns_none() {
314        assert_eq!(ToolProfile::parse("unknown"), None);
315        assert_eq!(ToolProfile::parse(""), None);
316    }
317
318    #[test]
319    fn minimal_profile_schema_budget() {
320        // tool_profile=minimal advertises the 9-tool surgical set; the schemas
321        // they re-send every turn (description + input schema) must stay small.
322        // This is the tool-side of the faithful-arm per-turn prefix tax (#361).
323        const MINIMAL_SCHEMA_BUDGET_TOKENS: usize = 2800;
324        let defs = crate::server::registry::build_registry().tool_defs();
325        let total: usize = defs
326            .iter()
327            .filter(|t| MINIMAL_TOOLS.contains(&t.name.as_ref()))
328            .map(crate::core::context_overhead::tool_tokens)
329            .sum();
330        assert!(total > 0, "minimal tools must exist in the registry");
331        assert!(
332            total <= MINIMAL_SCHEMA_BUDGET_TOKENS,
333            "minimal-profile tool schemas = {total} tok, budget {MINIMAL_SCHEMA_BUDGET_TOKENS}"
334        );
335    }
336
337    #[test]
338    fn minimal_is_subset_of_standard() {
339        for tool in MINIMAL_TOOLS {
340            assert!(
341                STANDARD_TOOLS.contains(tool),
342                "minimal tool {tool} missing from standard"
343            );
344        }
345    }
346
347    #[test]
348    fn power_enables_everything() {
349        let profile = ToolProfile::Power;
350        assert!(profile.is_tool_enabled("ctx_read"));
351        assert!(profile.is_tool_enabled("ctx_anything"));
352        assert!(profile.is_tool_enabled("nonexistent_tool"));
353    }
354
355    #[test]
356    fn minimal_filters_correctly() {
357        let profile = ToolProfile::Minimal;
358        assert!(profile.is_tool_enabled("ctx_read"));
359        assert!(profile.is_tool_enabled("ctx_shell"));
360        assert!(profile.is_tool_enabled("ctx_search"));
361        assert!(profile.is_tool_enabled("ctx_glob"));
362        assert!(profile.is_tool_enabled("ctx_tree"));
363        // #509: symbol/semantic lookups are now ctx_search actions, not their
364        // own minimal tools.
365        assert!(!profile.is_tool_enabled("ctx_symbol"));
366        assert!(!profile.is_tool_enabled("ctx_semantic_search"));
367        assert!(!profile.is_tool_enabled("ctx_callgraph"));
368        assert!(!profile.is_tool_enabled("ctx_benchmark"));
369    }
370
371    #[test]
372    fn standard_filters_correctly() {
373        let profile = ToolProfile::Standard;
374        assert!(profile.is_tool_enabled("ctx_read"));
375        assert!(profile.is_tool_enabled("ctx_compose"));
376        assert!(profile.is_tool_enabled("ctx_explore"));
377        assert!(profile.is_tool_enabled("ctx_glob"));
378        assert!(profile.is_tool_enabled("ctx_callgraph"));
379        assert!(profile.is_tool_enabled("ctx_graph"));
380        assert!(profile.is_tool_enabled("ctx_delta"));
381        assert!(profile.is_tool_enabled("ctx_expand"));
382        assert!(profile.is_tool_enabled("ctx_execute"));
383        assert!(profile.is_tool_enabled("ctx_overview"));
384        // #509: ctx_symbol + ctx_semantic_search folded into ctx_search (action=…),
385        // ctx_multi_read into ctx_read (paths=…) — all dropped from Standard.
386        assert!(!profile.is_tool_enabled("ctx_symbol"));
387        assert!(!profile.is_tool_enabled("ctx_semantic_search"));
388        assert!(!profile.is_tool_enabled("ctx_multi_read"));
389        assert!(profile.is_tool_enabled("ctx_url_read"));
390        // #1008: anchored editing is part of the pinned Standard surface.
391        assert!(profile.is_tool_enabled("ctx_patch"));
392        assert!(!profile.is_tool_enabled("ctx_benchmark"));
393        assert!(!profile.is_tool_enabled("ctx_analyze"));
394        assert!(!profile.is_tool_enabled("ctx_refactor"));
395        assert!(!profile.is_tool_enabled("ctx_edit"));
396    }
397
398    #[test]
399    fn custom_profile_uses_provided_list() {
400        let profile = ToolProfile::Custom(vec!["ctx_read".to_string(), "ctx_shell".to_string()]);
401        assert!(profile.is_tool_enabled("ctx_read"));
402        assert!(profile.is_tool_enabled("ctx_shell"));
403        assert!(!profile.is_tool_enabled("ctx_search"));
404    }
405
406    #[test]
407    fn profile_display_counts_match_tool_arrays() {
408        // The numbers shown by `lean-ctx tools` must equal the actual array
409        // lengths, so adding/removing a profile tool (e.g. the `shell` alias)
410        // can never silently desync the advertised count from reality.
411        let profiles = list_profiles();
412        assert_eq!(
413            profiles[0].tool_count.parse::<usize>().unwrap(),
414            MINIMAL_TOOLS.len(),
415            "minimal count must match MINIMAL_TOOLS length",
416        );
417        assert_eq!(
418            profiles[1].tool_count.parse::<usize>().unwrap(),
419            STANDARD_TOOLS.len(),
420            "standard count must match STANDARD_TOOLS length",
421        );
422        assert_eq!(profiles[2].tool_count, "all");
423    }
424
425    #[test]
426    fn custom_empty_enables_nothing() {
427        let profile = ToolProfile::Custom(vec![]);
428        assert!(!profile.is_tool_enabled("ctx_read"));
429    }
430
431    #[test]
432    fn display_matches_as_str() {
433        assert_eq!(format!("{}", ToolProfile::Minimal), "minimal");
434        assert_eq!(format!("{}", ToolProfile::Standard), "standard");
435        assert_eq!(format!("{}", ToolProfile::Power), "power");
436        assert_eq!(
437            format!("{}", ToolProfile::Custom(vec!["ctx_read".into()])),
438            "custom"
439        );
440    }
441
442    #[test]
443    fn tool_count_matches_list_length() {
444        assert_eq!(ToolProfile::Minimal.tool_count(), MINIMAL_TOOLS.len());
445        assert_eq!(ToolProfile::Standard.tool_count(), STANDARD_TOOLS.len());
446        assert_eq!(ToolProfile::Power.tool_count(), 0);
447    }
448
449    #[test]
450    fn from_config_defaults_to_power_for_backward_compat() {
451        if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
452            return;
453        }
454        let cfg = crate::core::config::Config {
455            tool_profile: None,
456            tools_enabled: vec![],
457            ..Default::default()
458        };
459        assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Power);
460    }
461
462    #[test]
463    fn from_config_respects_tool_profile_field() {
464        if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
465            return;
466        }
467        let cfg = crate::core::config::Config {
468            tool_profile: Some("minimal".to_string()),
469            tools_enabled: vec![],
470            ..Default::default()
471        };
472        assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Minimal);
473    }
474
475    #[test]
476    fn from_config_tools_enabled_creates_custom() {
477        if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
478            return;
479        }
480        let cfg = crate::core::config::Config {
481            tool_profile: None,
482            tools_enabled: vec!["ctx_read".to_string(), "ctx_shell".to_string()],
483            ..Default::default()
484        };
485        let profile = ToolProfile::from_config(&cfg);
486        assert_eq!(
487            profile,
488            ToolProfile::Custom(vec!["ctx_read".to_string(), "ctx_shell".to_string()])
489        );
490    }
491
492    #[test]
493    fn tool_profile_takes_precedence_over_tools_enabled() {
494        if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
495            return;
496        }
497        let cfg = crate::core::config::Config {
498            tool_profile: Some("standard".to_string()),
499            tools_enabled: vec!["ctx_read".to_string()],
500            ..Default::default()
501        };
502        assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Standard);
503    }
504
505    #[test]
506    fn empty_tool_profile_is_unpinned_so_tools_enabled_applies() {
507        // #613: `tool_profile = ""` is the documented default ("unpin"), not a
508        // bogus profile. It must resolve silently via the unpinned path (so an
509        // explicit `tools_enabled` takes effect) instead of warning
510        // "Unknown tool_profile ''".
511        assert!(is_unpinned_alias(""));
512        assert!(is_unpinned_alias("   "));
513
514        if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
515            return;
516        }
517        let cfg = crate::core::config::Config {
518            tool_profile: Some(String::new()),
519            tools_enabled: vec!["ctx_read".to_string()],
520            ..Default::default()
521        };
522        assert_eq!(
523            ToolProfile::from_config(&cfg),
524            ToolProfile::Custom(vec!["ctx_read".to_string()]),
525            "empty tool_profile must unpin so tools_enabled takes effect"
526        );
527    }
528
529    #[test]
530    fn all_profile_names_are_parseable() {
531        for name in PROFILE_NAMES {
532            assert!(
533                ToolProfile::parse(name).is_some(),
534                "profile name '{name}' should be parseable"
535            );
536        }
537    }
538
539    #[test]
540    fn list_profiles_returns_three_entries() {
541        let profiles = list_profiles();
542        assert_eq!(profiles.len(), 3);
543    }
544
545    #[test]
546    fn standard_includes_all_core_tools() {
547        let profile = ToolProfile::Standard;
548        assert!(
549            profile.is_tool_enabled("ctx_graph"),
550            "ctx_graph must be in standard"
551        );
552        assert!(
553            profile.is_tool_enabled("ctx_delta"),
554            "ctx_delta must be in standard"
555        );
556        assert!(
557            profile.is_tool_enabled("ctx_expand"),
558            "ctx_expand must be in standard"
559        );
560        assert!(
561            profile.is_tool_enabled("ctx_execute"),
562            "ctx_execute must be in standard (sandboxed code execution)"
563        );
564        // ctx_edit is power-only — for editing, Standard carries the anchored
565        // ctx_patch instead (#1008); str_replace stays a power fallback.
566        assert!(
567            !profile.is_tool_enabled("ctx_edit"),
568            "ctx_edit must NOT be in standard (ctx_patch is the standard editor)"
569        );
570        assert!(
571            profile.is_tool_enabled("ctx_patch"),
572            "ctx_patch must be in standard (#1008 anchored editing)"
573        );
574    }
575
576    #[test]
577    fn standard_includes_url_read() {
578        let profile = ToolProfile::Standard;
579        assert!(
580            profile.is_tool_enabled("ctx_url_read"),
581            "ctx_url_read must be in standard (web/research context)"
582        );
583    }
584
585    #[test]
586    fn clear_profile_removes_key_and_is_idempotent() {
587        let iso = crate::core::data_dir::isolated_data_dir();
588        set_profile_in_config("power").unwrap();
589        let config_path = iso.path().join("config.toml");
590        assert!(
591            std::fs::read_to_string(&config_path)
592                .unwrap()
593                .contains("tool_profile"),
594            "set_profile_in_config must write the key"
595        );
596
597        clear_profile_in_config().unwrap();
598        assert!(
599            !std::fs::read_to_string(&config_path)
600                .unwrap()
601                .contains("tool_profile"),
602            "clear_profile_in_config must remove the key (lean default, #575)"
603        );
604
605        // Idempotent: clearing again (and on a missing file) must not fail.
606        clear_profile_in_config().unwrap();
607    }
608
609    #[test]
610    fn clear_profile_on_missing_config_is_ok() {
611        let _iso = crate::core::data_dir::isolated_data_dir();
612        clear_profile_in_config().unwrap();
613    }
614
615    #[test]
616    fn without_tool_removes_from_power() {
617        let filtered = ToolProfile::Power.without_tool("ctx_patch");
618        assert!(!filtered.is_tool_enabled("ctx_patch"));
619        assert!(filtered.is_tool_enabled("ctx_read"));
620        assert!(filtered.is_tool_enabled("ctx_search"));
621    }
622
623    #[test]
624    fn without_tool_removes_from_standard() {
625        let filtered = ToolProfile::Standard.without_tool("ctx_patch");
626        assert!(!filtered.is_tool_enabled("ctx_patch"));
627        assert!(filtered.is_tool_enabled("ctx_read"));
628        assert!(filtered.is_tool_enabled("ctx_compose"));
629    }
630
631    #[test]
632    fn without_tool_noop_for_already_missing() {
633        let filtered = ToolProfile::Minimal.without_tool("ctx_patch");
634        assert!(!filtered.is_tool_enabled("ctx_patch"));
635        assert!(filtered.is_tool_enabled("ctx_read"));
636    }
637}