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 22 full schemas,
14/// `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 => "6 essential tools for new users",
45            Self::Standard => "22 balanced tools (recommended)",
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    pub fn is_tool_enabled(&self, tool_name: &str) -> bool {
58        match self {
59            Self::Power => true,
60            Self::Minimal => MINIMAL_TOOLS.contains(&tool_name),
61            Self::Standard => STANDARD_TOOLS.contains(&tool_name),
62            Self::Custom(list) => list.iter().any(|t| t == tool_name),
63        }
64    }
65
66    pub fn tool_count(&self) -> usize {
67        match self {
68            Self::Minimal => MINIMAL_TOOLS.len(),
69            Self::Standard => STANDARD_TOOLS.len(),
70            Self::Power => 0, // dynamic — caller should use registry count
71            Self::Custom(list) => list.len(),
72        }
73    }
74
75    pub fn tool_names(&self) -> Vec<&str> {
76        match self {
77            Self::Minimal => MINIMAL_TOOLS.to_vec(),
78            Self::Standard => STANDARD_TOOLS.to_vec(),
79            Self::Power | Self::Custom(_) => vec![],
80        }
81    }
82
83    /// Resolves the active tool profile from environment, then config.
84    ///
85    /// Priority: `LEAN_CTX_TOOL_PROFILE` env > config `tool_profile` > config `tools.enabled` > default.
86    /// Existing installs default to `power` (backward compat).
87    /// New installs set `standard` during setup.
88    pub fn from_config(cfg: &super::config::Config) -> Self {
89        if let Ok(val) = std::env::var("LEAN_CTX_TOOL_PROFILE") {
90            let trimmed = val.trim();
91            if let Some(profile) = Self::parse(trimmed) {
92                return profile;
93            }
94            // Same "unpin" sentinel handling as for the config key below (#431).
95            if !trimmed.is_empty() && !is_unpinned_alias(trimmed) {
96                tracing::warn!("Unknown LEAN_CTX_TOOL_PROFILE value '{trimmed}', using config");
97            }
98        }
99
100        if let Some(ref profile_name) = cfg.tool_profile {
101            if let Some(profile) = Self::parse(profile_name) {
102                return profile;
103            }
104            // `lean`/`lazy`/`reset` are the *unpinned* sentinel (lazy core
105            // advertised, everything reachable via ctx_call) — not a pinned
106            // tier. They can legitimately land in config (older versions, the
107            // dashboard's "Lean" button, manual edits), so resolve them
108            // silently to the default instead of warning + falling back (#431).
109            if !is_unpinned_alias(profile_name) {
110                tracing::warn!("Unknown tool_profile '{profile_name}' in config, using default");
111            }
112        }
113
114        if !cfg.tools_enabled.is_empty() {
115            return Self::Custom(cfg.tools_enabled.clone());
116        }
117
118        Self::Power
119    }
120}
121
122impl fmt::Display for ToolProfile {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        write!(f, "{}", self.as_str())
125    }
126}
127
128/// `lean`/`lazy`/`reset` are not pinned tiers — they are the *unpin* sentinel
129/// that clears any pin so the default returns (lazy core advertised, everything
130/// callable via `ctx_call`). Centralised so the config loader, the CLI
131/// (`lean-ctx profile lean`) and the dashboard all agree on the same set (#431).
132pub fn is_unpinned_alias(name: &str) -> bool {
133    matches!(
134        name.trim().to_ascii_lowercase().as_str(),
135        "lean" | "lazy" | "reset"
136    )
137}
138
139const MINIMAL_TOOLS: &[&str] = &[
140    "ctx_read",
141    "ctx_shell",
142    "shell",
143    "ctx_search",
144    "ctx_tree",
145    "ctx_session",
146];
147
148const STANDARD_TOOLS: &[&str] = &[
149    // Everything in minimal
150    "ctx_read",
151    "ctx_shell",
152    "shell",
153    "ctx_search",
154    "ctx_tree",
155    "ctx_session",
156    // Plus balanced additions
157    "ctx_semantic_search",
158    "ctx_knowledge",
159    "ctx_overview",
160    "ctx_repomap",
161    "ctx_callgraph",
162    "ctx_impact",
163    "ctx_compress",
164    "ctx_multi_read",
165    "ctx_delta",
166    "ctx_edit",
167    "ctx_agent",
168    "ctx_architecture",
169    "ctx_pack",
170    "ctx_routes",
171    "ctx_refactor",
172    // Web/research context: fetch + cite external pages and YouTube transcripts
173    "ctx_url_read",
174];
175
176/// Available built-in profile names.
177pub const PROFILE_NAMES: &[&str] = &["minimal", "standard", "power"];
178
179pub struct ProfileInfo {
180    pub name: &'static str,
181    pub tool_count: &'static str,
182    pub description: &'static str,
183}
184
185pub fn list_profiles() -> Vec<ProfileInfo> {
186    vec![
187        ProfileInfo {
188            name: "minimal",
189            tool_count: "6",
190            description: "Essential tools for new users / skeptics",
191        },
192        ProfileInfo {
193            name: "standard",
194            tool_count: "22",
195            description: "Balanced set (recommended for most users)",
196        },
197        ProfileInfo {
198            name: "power",
199            tool_count: "all",
200            description: "Every tool exposed (backward compatible)",
201        },
202    ]
203}
204
205/// Writes the `tool_profile` setting to config.toml, preserving all comments,
206/// formatting, and unrelated keys (robust against substring/comment matches).
207pub fn set_profile_in_config(profile_name: &str) -> Result<(), String> {
208    // Canonical config location (RO-safe config category, GH #408). Writing it
209    // anywhere else than `Config::load` reads would split-brain once the data
210    // default flips to `$XDG_DATA_HOME`.
211    let config_path = crate::core::config::Config::path()
212        .ok_or_else(|| "Cannot determine config dir".to_string())?;
213
214    let mut doc = crate::config_io::load_toml_document(&config_path);
215    doc["tool_profile"] = toml_edit::value(profile_name);
216    crate::config_io::write_toml_document(&config_path, &doc)?;
217    Ok(())
218}
219
220/// Removes the `tool_profile` key from config.toml, restoring the lean
221/// default: only the lazy core set is advertised in `tools/list`,
222/// while every registered tool stays reachable through `ctx_call`. This is
223/// the recommended low-overhead mode (#575).
224pub fn clear_profile_in_config() -> Result<(), String> {
225    let config_path = crate::core::config::Config::path()
226        .ok_or_else(|| "Cannot determine config dir".to_string())?;
227    if !config_path.exists() {
228        return Ok(());
229    }
230
231    let mut doc = crate::config_io::load_toml_document(&config_path);
232    if doc.remove("tool_profile").is_none() {
233        return Ok(());
234    }
235    crate::config_io::write_toml_document(&config_path, &doc)?;
236    Ok(())
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn parse_known_profiles() {
245        assert_eq!(ToolProfile::parse("minimal"), Some(ToolProfile::Minimal));
246        assert_eq!(ToolProfile::parse("min"), Some(ToolProfile::Minimal));
247        assert_eq!(ToolProfile::parse("standard"), Some(ToolProfile::Standard));
248        assert_eq!(ToolProfile::parse("std"), Some(ToolProfile::Standard));
249        assert_eq!(ToolProfile::parse("default"), Some(ToolProfile::Standard));
250        assert_eq!(ToolProfile::parse("power"), Some(ToolProfile::Power));
251        assert_eq!(ToolProfile::parse("full"), Some(ToolProfile::Power));
252        assert_eq!(ToolProfile::parse("all"), Some(ToolProfile::Power));
253    }
254
255    #[test]
256    fn parse_case_insensitive() {
257        assert_eq!(ToolProfile::parse("MINIMAL"), Some(ToolProfile::Minimal));
258        assert_eq!(ToolProfile::parse("Standard"), Some(ToolProfile::Standard));
259        assert_eq!(ToolProfile::parse("POWER"), Some(ToolProfile::Power));
260    }
261
262    #[test]
263    fn parse_unknown_returns_none() {
264        assert_eq!(ToolProfile::parse("unknown"), None);
265        assert_eq!(ToolProfile::parse(""), None);
266    }
267
268    #[test]
269    fn minimal_has_6_tools() {
270        assert_eq!(MINIMAL_TOOLS.len(), 6);
271    }
272
273    #[test]
274    fn minimal_profile_schema_budget() {
275        // tool_profile=minimal advertises the 6-tool lean set; the schemas they
276        // re-send every turn (description + input schema) must stay small — this
277        // is the tool-side of the faithful-arm per-turn prefix tax (#361). We
278        // sum the registry's *uncompressed* schemas (a strict upper bound on the
279        // advertised, description-compressed surface).
280        const MINIMAL_SCHEMA_BUDGET_TOKENS: usize = 1500;
281        let defs = crate::server::registry::build_registry().tool_defs();
282        let total: usize = defs
283            .iter()
284            .filter(|t| MINIMAL_TOOLS.contains(&t.name.as_ref()))
285            .map(crate::core::context_overhead::tool_tokens)
286            .sum();
287        assert!(total > 0, "minimal tools must exist in the registry");
288        assert!(
289            total <= MINIMAL_SCHEMA_BUDGET_TOKENS,
290            "minimal-profile tool schemas = {total} tok, budget {MINIMAL_SCHEMA_BUDGET_TOKENS}"
291        );
292    }
293
294    #[test]
295    fn standard_has_22_tools() {
296        assert_eq!(STANDARD_TOOLS.len(), 22);
297    }
298
299    #[test]
300    fn minimal_is_subset_of_standard() {
301        for tool in MINIMAL_TOOLS {
302            assert!(
303                STANDARD_TOOLS.contains(tool),
304                "minimal tool {tool} missing from standard"
305            );
306        }
307    }
308
309    #[test]
310    fn power_enables_everything() {
311        let profile = ToolProfile::Power;
312        assert!(profile.is_tool_enabled("ctx_read"));
313        assert!(profile.is_tool_enabled("ctx_anything"));
314        assert!(profile.is_tool_enabled("nonexistent_tool"));
315    }
316
317    #[test]
318    fn minimal_filters_correctly() {
319        let profile = ToolProfile::Minimal;
320        assert!(profile.is_tool_enabled("ctx_read"));
321        assert!(profile.is_tool_enabled("ctx_shell"));
322        assert!(profile.is_tool_enabled("ctx_search"));
323        assert!(profile.is_tool_enabled("ctx_tree"));
324        assert!(profile.is_tool_enabled("ctx_session"));
325        assert!(!profile.is_tool_enabled("ctx_semantic_search"));
326        assert!(!profile.is_tool_enabled("ctx_architecture"));
327        assert!(!profile.is_tool_enabled("ctx_benchmark"));
328    }
329
330    #[test]
331    fn standard_filters_correctly() {
332        let profile = ToolProfile::Standard;
333        assert!(profile.is_tool_enabled("ctx_read"));
334        assert!(profile.is_tool_enabled("ctx_semantic_search"));
335        assert!(profile.is_tool_enabled("ctx_architecture"));
336        assert!(!profile.is_tool_enabled("ctx_benchmark"));
337        assert!(!profile.is_tool_enabled("ctx_analyze"));
338        assert!(!profile.is_tool_enabled("ctx_smells"));
339    }
340
341    #[test]
342    fn custom_profile_uses_provided_list() {
343        let profile = ToolProfile::Custom(vec!["ctx_read".to_string(), "ctx_shell".to_string()]);
344        assert!(profile.is_tool_enabled("ctx_read"));
345        assert!(profile.is_tool_enabled("ctx_shell"));
346        assert!(!profile.is_tool_enabled("ctx_search"));
347    }
348
349    #[test]
350    fn profile_display_counts_match_tool_arrays() {
351        // The numbers shown by `lean-ctx tools` must equal the actual array
352        // lengths, so adding/removing a profile tool (e.g. the `shell` alias)
353        // can never silently desync the advertised count from reality.
354        let profiles = list_profiles();
355        assert_eq!(
356            profiles[0].tool_count.parse::<usize>().unwrap(),
357            MINIMAL_TOOLS.len(),
358            "minimal count must match MINIMAL_TOOLS length",
359        );
360        assert_eq!(
361            profiles[1].tool_count.parse::<usize>().unwrap(),
362            STANDARD_TOOLS.len(),
363            "standard count must match STANDARD_TOOLS length",
364        );
365        assert_eq!(profiles[2].tool_count, "all");
366    }
367
368    #[test]
369    fn custom_empty_enables_nothing() {
370        let profile = ToolProfile::Custom(vec![]);
371        assert!(!profile.is_tool_enabled("ctx_read"));
372    }
373
374    #[test]
375    fn display_matches_as_str() {
376        assert_eq!(format!("{}", ToolProfile::Minimal), "minimal");
377        assert_eq!(format!("{}", ToolProfile::Standard), "standard");
378        assert_eq!(format!("{}", ToolProfile::Power), "power");
379        assert_eq!(
380            format!("{}", ToolProfile::Custom(vec!["ctx_read".into()])),
381            "custom"
382        );
383    }
384
385    #[test]
386    fn tool_count_matches_list_length() {
387        assert_eq!(ToolProfile::Minimal.tool_count(), MINIMAL_TOOLS.len());
388        assert_eq!(ToolProfile::Standard.tool_count(), STANDARD_TOOLS.len());
389        assert_eq!(ToolProfile::Power.tool_count(), 0);
390    }
391
392    #[test]
393    fn from_config_defaults_to_power_for_backward_compat() {
394        if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
395            return;
396        }
397        let cfg = crate::core::config::Config {
398            tool_profile: None,
399            tools_enabled: vec![],
400            ..Default::default()
401        };
402        assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Power);
403    }
404
405    #[test]
406    fn from_config_respects_tool_profile_field() {
407        if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
408            return;
409        }
410        let cfg = crate::core::config::Config {
411            tool_profile: Some("minimal".to_string()),
412            tools_enabled: vec![],
413            ..Default::default()
414        };
415        assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Minimal);
416    }
417
418    #[test]
419    fn from_config_tools_enabled_creates_custom() {
420        if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
421            return;
422        }
423        let cfg = crate::core::config::Config {
424            tool_profile: None,
425            tools_enabled: vec!["ctx_read".to_string(), "ctx_shell".to_string()],
426            ..Default::default()
427        };
428        let profile = ToolProfile::from_config(&cfg);
429        assert_eq!(
430            profile,
431            ToolProfile::Custom(vec!["ctx_read".to_string(), "ctx_shell".to_string()])
432        );
433    }
434
435    #[test]
436    fn tool_profile_takes_precedence_over_tools_enabled() {
437        if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
438            return;
439        }
440        let cfg = crate::core::config::Config {
441            tool_profile: Some("standard".to_string()),
442            tools_enabled: vec!["ctx_read".to_string()],
443            ..Default::default()
444        };
445        assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Standard);
446    }
447
448    #[test]
449    fn all_profile_names_are_parseable() {
450        for name in PROFILE_NAMES {
451            assert!(
452                ToolProfile::parse(name).is_some(),
453                "profile name '{name}' should be parseable"
454            );
455        }
456    }
457
458    #[test]
459    fn list_profiles_returns_three_entries() {
460        let profiles = list_profiles();
461        assert_eq!(profiles.len(), 3);
462    }
463
464    #[test]
465    fn standard_includes_edit_and_delta() {
466        let profile = ToolProfile::Standard;
467        assert!(
468            profile.is_tool_enabled("ctx_edit"),
469            "ctx_edit must be in standard"
470        );
471        assert!(
472            profile.is_tool_enabled("ctx_delta"),
473            "ctx_delta must be in standard"
474        );
475    }
476
477    #[test]
478    fn standard_includes_url_read() {
479        let profile = ToolProfile::Standard;
480        assert!(
481            profile.is_tool_enabled("ctx_url_read"),
482            "ctx_url_read must be in standard (web/research context)"
483        );
484    }
485
486    #[test]
487    fn clear_profile_removes_key_and_is_idempotent() {
488        let iso = crate::core::data_dir::isolated_data_dir();
489        set_profile_in_config("power").unwrap();
490        let config_path = iso.path().join("config.toml");
491        assert!(
492            std::fs::read_to_string(&config_path)
493                .unwrap()
494                .contains("tool_profile"),
495            "set_profile_in_config must write the key"
496        );
497
498        clear_profile_in_config().unwrap();
499        assert!(
500            !std::fs::read_to_string(&config_path)
501                .unwrap()
502                .contains("tool_profile"),
503            "clear_profile_in_config must remove the key (lean default, #575)"
504        );
505
506        // Idempotent: clearing again (and on a missing file) must not fail.
507        clear_profile_in_config().unwrap();
508    }
509
510    #[test]
511    fn clear_profile_on_missing_config_is_ok() {
512        let _iso = crate::core::data_dir::isolated_data_dir();
513        clear_profile_in_config().unwrap();
514    }
515}