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 19 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 => "5 surgical tools — each irreplaceable (recommended)",
45            Self::Standard => "15 balanced tools (adds compose, explore, callgraph, execute, 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    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
139/// Surgical core — each tool is irreplaceable. Agent learns <10 tools,
140/// reliably picks the right one for every intent.
141///
142/// #509: symbol lookup folded into `ctx_search` (action="symbol"), so the
143/// former `ctx_symbol` entry is gone — one search entry, not two.
144const MINIMAL_TOOLS: &[&str] = &[
145    "ctx_read",
146    "ctx_shell",
147    "ctx_search",
148    "ctx_glob",
149    "ctx_tree",
150];
151
152/// Balanced set. Adds power-user tools the agent picks regularly but
153/// are not essential for every session.
154///
155/// #509: `ctx_semantic_search` and `ctx_symbol` are folded into `ctx_search`
156/// (action="semantic"/"symbol") and dropped from the advertised set.
157const STANDARD_TOOLS: &[&str] = &[
158    "ctx_read",
159    "ctx_shell",
160    "ctx_search",
161    "ctx_glob",
162    "ctx_tree",
163    "ctx_compose",
164    "ctx_explore",
165    "ctx_knowledge",
166    "ctx_callgraph",
167    "ctx_graph",
168    "ctx_delta",
169    "ctx_execute",
170    "ctx_expand",
171    "ctx_overview",
172    "ctx_url_read",
173];
174
175/// Available built-in profile names.
176pub const PROFILE_NAMES: &[&str] = &["minimal", "standard", "power"];
177
178pub struct ProfileInfo {
179    pub name: &'static str,
180    pub tool_count: &'static str,
181    pub description: &'static str,
182}
183
184pub fn list_profiles() -> Vec<ProfileInfo> {
185    vec![
186        ProfileInfo {
187            name: "minimal",
188            tool_count: "5",
189            description: "Surgical core — each tool irreplaceable (recommended)",
190        },
191        ProfileInfo {
192            name: "standard",
193            tool_count: "15",
194            description: "Balanced set — adds compose, explore, callgraph, execute, delta, more",
195        },
196        ProfileInfo {
197            name: "power",
198            tool_count: "all",
199            description: "Every tool exposed (backward compatible)",
200        },
201    ]
202}
203
204/// Writes the `tool_profile` setting to config.toml, preserving all comments,
205/// formatting, and unrelated keys (robust against substring/comment matches).
206pub fn set_profile_in_config(profile_name: &str) -> Result<(), String> {
207    // Canonical config location (RO-safe config category, GH #408). Writing it
208    // anywhere else than `Config::load` reads would split-brain once the data
209    // default flips to `$XDG_DATA_HOME`.
210    let config_path = crate::core::config::Config::path()
211        .ok_or_else(|| "Cannot determine config dir".to_string())?;
212
213    let mut doc = crate::config_io::load_toml_document(&config_path);
214    doc["tool_profile"] = toml_edit::value(profile_name);
215    crate::config_io::write_toml_document(&config_path, &doc)?;
216    Ok(())
217}
218
219/// Removes the `tool_profile` key from config.toml, restoring the lean
220/// default: only the lazy core set is advertised in `tools/list`,
221/// while every registered tool stays reachable through `ctx_call`. This is
222/// the recommended low-overhead mode (#575).
223pub fn clear_profile_in_config() -> Result<(), String> {
224    let config_path = crate::core::config::Config::path()
225        .ok_or_else(|| "Cannot determine config dir".to_string())?;
226    if !config_path.exists() {
227        return Ok(());
228    }
229
230    let mut doc = crate::config_io::load_toml_document(&config_path);
231    if doc.remove("tool_profile").is_none() {
232        return Ok(());
233    }
234    crate::config_io::write_toml_document(&config_path, &doc)?;
235    Ok(())
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn parse_known_profiles() {
244        assert_eq!(ToolProfile::parse("minimal"), Some(ToolProfile::Minimal));
245        assert_eq!(ToolProfile::parse("min"), Some(ToolProfile::Minimal));
246        assert_eq!(ToolProfile::parse("standard"), Some(ToolProfile::Standard));
247        assert_eq!(ToolProfile::parse("std"), Some(ToolProfile::Standard));
248        assert_eq!(ToolProfile::parse("default"), Some(ToolProfile::Standard));
249        assert_eq!(ToolProfile::parse("power"), Some(ToolProfile::Power));
250        assert_eq!(ToolProfile::parse("full"), Some(ToolProfile::Power));
251        assert_eq!(ToolProfile::parse("all"), Some(ToolProfile::Power));
252    }
253
254    #[test]
255    fn parse_case_insensitive() {
256        assert_eq!(ToolProfile::parse("MINIMAL"), Some(ToolProfile::Minimal));
257        assert_eq!(ToolProfile::parse("Standard"), Some(ToolProfile::Standard));
258        assert_eq!(ToolProfile::parse("POWER"), Some(ToolProfile::Power));
259    }
260
261    #[test]
262    fn parse_unknown_returns_none() {
263        assert_eq!(ToolProfile::parse("unknown"), None);
264        assert_eq!(ToolProfile::parse(""), None);
265    }
266
267    #[test]
268    fn minimal_profile_schema_budget() {
269        // tool_profile=minimal advertises the 9-tool surgical set; the schemas
270        // they re-send every turn (description + input schema) must stay small.
271        // This is the tool-side of the faithful-arm per-turn prefix tax (#361).
272        const MINIMAL_SCHEMA_BUDGET_TOKENS: usize = 2800;
273        let defs = crate::server::registry::build_registry().tool_defs();
274        let total: usize = defs
275            .iter()
276            .filter(|t| MINIMAL_TOOLS.contains(&t.name.as_ref()))
277            .map(crate::core::context_overhead::tool_tokens)
278            .sum();
279        assert!(total > 0, "minimal tools must exist in the registry");
280        assert!(
281            total <= MINIMAL_SCHEMA_BUDGET_TOKENS,
282            "minimal-profile tool schemas = {total} tok, budget {MINIMAL_SCHEMA_BUDGET_TOKENS}"
283        );
284    }
285
286    #[test]
287    fn minimal_is_subset_of_standard() {
288        for tool in MINIMAL_TOOLS {
289            assert!(
290                STANDARD_TOOLS.contains(tool),
291                "minimal tool {tool} missing from standard"
292            );
293        }
294    }
295
296    #[test]
297    fn power_enables_everything() {
298        let profile = ToolProfile::Power;
299        assert!(profile.is_tool_enabled("ctx_read"));
300        assert!(profile.is_tool_enabled("ctx_anything"));
301        assert!(profile.is_tool_enabled("nonexistent_tool"));
302    }
303
304    #[test]
305    fn minimal_filters_correctly() {
306        let profile = ToolProfile::Minimal;
307        assert!(profile.is_tool_enabled("ctx_read"));
308        assert!(profile.is_tool_enabled("ctx_shell"));
309        assert!(profile.is_tool_enabled("ctx_search"));
310        assert!(profile.is_tool_enabled("ctx_glob"));
311        assert!(profile.is_tool_enabled("ctx_tree"));
312        // #509: symbol/semantic lookups are now ctx_search actions, not their
313        // own minimal tools.
314        assert!(!profile.is_tool_enabled("ctx_symbol"));
315        assert!(!profile.is_tool_enabled("ctx_semantic_search"));
316        assert!(!profile.is_tool_enabled("ctx_callgraph"));
317        assert!(!profile.is_tool_enabled("ctx_benchmark"));
318    }
319
320    #[test]
321    fn standard_filters_correctly() {
322        let profile = ToolProfile::Standard;
323        assert!(profile.is_tool_enabled("ctx_read"));
324        assert!(profile.is_tool_enabled("ctx_compose"));
325        assert!(profile.is_tool_enabled("ctx_explore"));
326        assert!(profile.is_tool_enabled("ctx_glob"));
327        assert!(profile.is_tool_enabled("ctx_callgraph"));
328        assert!(profile.is_tool_enabled("ctx_graph"));
329        assert!(profile.is_tool_enabled("ctx_delta"));
330        assert!(profile.is_tool_enabled("ctx_expand"));
331        assert!(profile.is_tool_enabled("ctx_execute"));
332        assert!(profile.is_tool_enabled("ctx_overview"));
333        // #509: ctx_symbol + ctx_semantic_search folded into ctx_search (action=…),
334        // ctx_multi_read into ctx_read (paths=…) — all dropped from Standard.
335        assert!(!profile.is_tool_enabled("ctx_symbol"));
336        assert!(!profile.is_tool_enabled("ctx_semantic_search"));
337        assert!(!profile.is_tool_enabled("ctx_multi_read"));
338        assert!(profile.is_tool_enabled("ctx_url_read"));
339        assert!(!profile.is_tool_enabled("ctx_benchmark"));
340        assert!(!profile.is_tool_enabled("ctx_analyze"));
341        assert!(!profile.is_tool_enabled("ctx_refactor"));
342        assert!(!profile.is_tool_enabled("ctx_edit"));
343    }
344
345    #[test]
346    fn custom_profile_uses_provided_list() {
347        let profile = ToolProfile::Custom(vec!["ctx_read".to_string(), "ctx_shell".to_string()]);
348        assert!(profile.is_tool_enabled("ctx_read"));
349        assert!(profile.is_tool_enabled("ctx_shell"));
350        assert!(!profile.is_tool_enabled("ctx_search"));
351    }
352
353    #[test]
354    fn profile_display_counts_match_tool_arrays() {
355        // The numbers shown by `lean-ctx tools` must equal the actual array
356        // lengths, so adding/removing a profile tool (e.g. the `shell` alias)
357        // can never silently desync the advertised count from reality.
358        let profiles = list_profiles();
359        assert_eq!(
360            profiles[0].tool_count.parse::<usize>().unwrap(),
361            MINIMAL_TOOLS.len(),
362            "minimal count must match MINIMAL_TOOLS length",
363        );
364        assert_eq!(
365            profiles[1].tool_count.parse::<usize>().unwrap(),
366            STANDARD_TOOLS.len(),
367            "standard count must match STANDARD_TOOLS length",
368        );
369        assert_eq!(profiles[2].tool_count, "all");
370    }
371
372    #[test]
373    fn custom_empty_enables_nothing() {
374        let profile = ToolProfile::Custom(vec![]);
375        assert!(!profile.is_tool_enabled("ctx_read"));
376    }
377
378    #[test]
379    fn display_matches_as_str() {
380        assert_eq!(format!("{}", ToolProfile::Minimal), "minimal");
381        assert_eq!(format!("{}", ToolProfile::Standard), "standard");
382        assert_eq!(format!("{}", ToolProfile::Power), "power");
383        assert_eq!(
384            format!("{}", ToolProfile::Custom(vec!["ctx_read".into()])),
385            "custom"
386        );
387    }
388
389    #[test]
390    fn tool_count_matches_list_length() {
391        assert_eq!(ToolProfile::Minimal.tool_count(), MINIMAL_TOOLS.len());
392        assert_eq!(ToolProfile::Standard.tool_count(), STANDARD_TOOLS.len());
393        assert_eq!(ToolProfile::Power.tool_count(), 0);
394    }
395
396    #[test]
397    fn from_config_defaults_to_power_for_backward_compat() {
398        if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
399            return;
400        }
401        let cfg = crate::core::config::Config {
402            tool_profile: None,
403            tools_enabled: vec![],
404            ..Default::default()
405        };
406        assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Power);
407    }
408
409    #[test]
410    fn from_config_respects_tool_profile_field() {
411        if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
412            return;
413        }
414        let cfg = crate::core::config::Config {
415            tool_profile: Some("minimal".to_string()),
416            tools_enabled: vec![],
417            ..Default::default()
418        };
419        assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Minimal);
420    }
421
422    #[test]
423    fn from_config_tools_enabled_creates_custom() {
424        if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
425            return;
426        }
427        let cfg = crate::core::config::Config {
428            tool_profile: None,
429            tools_enabled: vec!["ctx_read".to_string(), "ctx_shell".to_string()],
430            ..Default::default()
431        };
432        let profile = ToolProfile::from_config(&cfg);
433        assert_eq!(
434            profile,
435            ToolProfile::Custom(vec!["ctx_read".to_string(), "ctx_shell".to_string()])
436        );
437    }
438
439    #[test]
440    fn tool_profile_takes_precedence_over_tools_enabled() {
441        if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
442            return;
443        }
444        let cfg = crate::core::config::Config {
445            tool_profile: Some("standard".to_string()),
446            tools_enabled: vec!["ctx_read".to_string()],
447            ..Default::default()
448        };
449        assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Standard);
450    }
451
452    #[test]
453    fn all_profile_names_are_parseable() {
454        for name in PROFILE_NAMES {
455            assert!(
456                ToolProfile::parse(name).is_some(),
457                "profile name '{name}' should be parseable"
458            );
459        }
460    }
461
462    #[test]
463    fn list_profiles_returns_three_entries() {
464        let profiles = list_profiles();
465        assert_eq!(profiles.len(), 3);
466    }
467
468    #[test]
469    fn standard_includes_all_core_tools() {
470        let profile = ToolProfile::Standard;
471        assert!(
472            profile.is_tool_enabled("ctx_graph"),
473            "ctx_graph must be in standard"
474        );
475        assert!(
476            profile.is_tool_enabled("ctx_delta"),
477            "ctx_delta must be in standard"
478        );
479        assert!(
480            profile.is_tool_enabled("ctx_expand"),
481            "ctx_expand must be in standard"
482        );
483        assert!(
484            profile.is_tool_enabled("ctx_execute"),
485            "ctx_execute must be in standard (sandboxed code execution)"
486        );
487        // ctx_edit is power-only — native Edit tool is preferred
488        assert!(
489            !profile.is_tool_enabled("ctx_edit"),
490            "ctx_edit must NOT be in standard (native Edit preferred)"
491        );
492    }
493
494    #[test]
495    fn standard_includes_url_read() {
496        let profile = ToolProfile::Standard;
497        assert!(
498            profile.is_tool_enabled("ctx_url_read"),
499            "ctx_url_read must be in standard (web/research context)"
500        );
501    }
502
503    #[test]
504    fn clear_profile_removes_key_and_is_idempotent() {
505        let iso = crate::core::data_dir::isolated_data_dir();
506        set_profile_in_config("power").unwrap();
507        let config_path = iso.path().join("config.toml");
508        assert!(
509            std::fs::read_to_string(&config_path)
510                .unwrap()
511                .contains("tool_profile"),
512            "set_profile_in_config must write the key"
513        );
514
515        clear_profile_in_config().unwrap();
516        assert!(
517            !std::fs::read_to_string(&config_path)
518                .unwrap()
519                .contains("tool_profile"),
520            "clear_profile_in_config must remove the key (lean default, #575)"
521        );
522
523        // Idempotent: clearing again (and on a missing file) must not fail.
524        clear_profile_in_config().unwrap();
525    }
526
527    #[test]
528    fn clear_profile_on_missing_config_is_ok() {
529        let _iso = crate::core::data_dir::isolated_data_dir();
530        clear_profile_in_config().unwrap();
531    }
532}