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