1use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
15
16use super::dynamic_tools::{ToolCategory, categorize_tool};
17use crate::core::tool_profiles::ToolProfile;
18
19static AUTO_TURN_COUNT: AtomicU64 = AtomicU64::new(0);
22static AUTO_CTX_TOOLS_USED: AtomicBool = AtomicBool::new(false);
23static AUTO_SYSTEM_PROMPT_TOKENS: AtomicUsize = AtomicUsize::new(0);
24
25pub fn record_auto_turn() {
27 AUTO_TURN_COUNT.fetch_add(1, Ordering::Relaxed);
28}
29
30pub fn mark_auto_ctx_tool_used() {
32 AUTO_CTX_TOOLS_USED.store(true, Ordering::Relaxed);
33}
34
35pub fn set_auto_system_prompt_tokens(tokens: usize) {
38 AUTO_SYSTEM_PROMPT_TOKENS.store(tokens, Ordering::Relaxed);
39}
40
41#[must_use]
44pub fn resolve_auto_profile(profile: &ToolProfile) -> ToolProfile {
45 if *profile != ToolProfile::Auto {
46 return profile.clone();
47 }
48 ToolProfile::resolve_auto(
49 AUTO_TURN_COUNT.load(Ordering::Relaxed),
50 AUTO_CTX_TOOLS_USED.load(Ordering::Relaxed),
51 AUTO_SYSTEM_PROMPT_TOKENS.load(Ordering::Relaxed),
52 )
53}
54
55pub const INVOKER: &str = "ctx_call";
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum CandidateSet {
62 Full,
64 Unified,
66 ProfileAuthoritative,
69 LazyCore,
72 ShadowOnly,
76}
77
78pub struct CandidateInputs {
80 pub full_mode: bool,
81 pub unified_env: bool,
82 pub explicit_profile: bool,
83 pub hook_covered: bool,
84}
85
86#[must_use]
90pub fn candidate_set(inp: &CandidateInputs) -> CandidateSet {
91 if inp.full_mode {
92 CandidateSet::Full
93 } else if inp.unified_env {
94 CandidateSet::Unified
95 } else if inp.explicit_profile {
96 CandidateSet::ProfileAuthoritative
97 } else if inp.hook_covered && is_shadow_surface_enabled() {
98 CandidateSet::ShadowOnly
99 } else {
100 CandidateSet::LazyCore
101 }
102}
103
104fn is_shadow_surface_enabled() -> bool {
106 if let Ok(v) = std::env::var("LEAN_CTX_TOOL_SURFACE") {
107 return v.eq_ignore_ascii_case("shadow") || v.eq_ignore_ascii_case("auto");
108 }
109 let cfg = crate::core::config::Config::load();
110 !matches!(cfg.tool_surface.as_deref(), Some("mcp"))
113}
114
115#[must_use]
118pub fn explicit_profile(cfg: &crate::core::config::Config) -> bool {
119 cfg.tool_profile.is_some()
120 || !cfg.tools_enabled.is_empty()
121 || std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok()
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
130pub struct ClientQuirks {
131 pub hide_ctx_edit: bool,
133 pub hide_ctx_patch: bool,
138}
139
140impl ClientQuirks {
141 #[must_use]
143 pub fn resolve(client_name: &str, candidate: CandidateSet) -> Self {
144 let lower = client_name.to_lowercase();
145 Self {
146 hide_ctx_edit: lower.contains("zed"),
147 hide_ctx_patch: candidate == CandidateSet::LazyCore && has_native_editor(&lower),
148 }
149 }
150}
151
152fn has_native_editor(lower_client_name: &str) -> bool {
159 [
160 "cursor",
161 "zed",
162 "windsurf",
163 "codeium",
164 "antigravity",
165 "opencode",
166 ]
167 .iter()
168 .any(|c| lower_client_name.contains(c))
169}
170
171#[must_use]
177pub fn is_tool_visible(
178 name: &str,
179 profile: &ToolProfile,
180 disabled: &[String],
181 quirks: ClientQuirks,
182 role_allows: bool,
183) -> bool {
184 if categorize_tool(name) == ToolCategory::Internal {
185 return false;
186 }
187 if super::dynamic_tools::is_deprecated_alias(name) {
190 return false;
191 }
192 if !profile.is_tool_enabled(name) {
193 return false;
194 }
195 if disabled.iter().any(|d| d == name) {
196 return false;
197 }
198 if quirks.hide_ctx_edit && name == "ctx_edit" {
199 return false;
200 }
201 if quirks.hide_ctx_patch && name == "ctx_patch" {
202 return false;
203 }
204 role_allows
205}
206
207#[must_use]
215pub fn advertised_tool_defs_default() -> Vec<rmcp::model::Tool> {
216 let cfg = crate::core::config::Config::load();
217 let disabled = cfg.disabled_tools_effective();
218 let profile = cfg.tool_profile_effective();
219 let full_mode = crate::tool_defs::is_full_mode();
220 let registry = crate::server::registry::build_registry();
221
222 let candidate = candidate_set(&CandidateInputs {
223 full_mode,
224 unified_env: std::env::var("LEAN_CTX_UNIFIED").is_ok(),
225 explicit_profile: explicit_profile(&cfg),
226 hook_covered: false, });
228 let pool: Vec<rmcp::model::Tool> = match candidate {
229 CandidateSet::Full | CandidateSet::ProfileAuthoritative => registry.tool_defs(),
230 CandidateSet::Unified => crate::tool_defs::unified_tool_defs(),
231 CandidateSet::ShadowOnly => registry
232 .tool_defs()
233 .into_iter()
234 .filter(|t| t.name.as_ref() == INVOKER)
235 .collect(),
236 CandidateSet::LazyCore => {
237 let core = crate::tool_defs::core_tool_names();
238 registry
239 .tool_defs()
240 .into_iter()
241 .filter(|t| core.contains(&t.name.as_ref()))
242 .collect()
243 }
244 };
245
246 let mut tools: Vec<_> = pool
247 .into_iter()
248 .filter(|t| {
249 is_tool_visible(
250 t.name.as_ref(),
251 &profile,
252 &disabled,
253 ClientQuirks::default(),
254 true,
255 )
256 })
257 .collect();
258
259 let already = tools.iter().any(|t| t.name.as_ref() == INVOKER);
260 if needs_invoker(full_mode, already, true, &disabled)
261 && let Some(def) = registry
262 .tool_defs()
263 .into_iter()
264 .find(|t| t.name.as_ref() == INVOKER)
265 {
266 tools.push(def);
267 }
268
269 let level = crate::core::config::CompressionLevel::effective(&cfg);
270 let mode = crate::core::terse::mcp_compress::DescriptionMode::from_compression_level(&level);
271 if mode == crate::core::terse::mcp_compress::DescriptionMode::Full {
272 return tools;
273 }
274 tools
275 .into_iter()
276 .map(|mut t| {
277 let compressed = crate::core::terse::mcp_compress::compress_description(
278 t.name.as_ref(),
279 t.description.as_deref().unwrap_or(""),
280 mode,
281 );
282 t.description = Some(compressed.into());
283 t
284 })
285 .collect()
286}
287
288#[must_use]
298pub fn category_gate_applies(supports_list_changed: bool, explicit_profile: bool) -> bool {
299 supports_list_changed && !explicit_profile
300}
301
302#[must_use]
308pub fn needs_invoker(
309 full_mode: bool,
310 already_present: bool,
311 invoker_role_allowed: bool,
312 disabled: &[String],
313) -> bool {
314 !full_mode && !already_present && invoker_role_allowed && !disabled.iter().any(|d| d == INVOKER)
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 fn no_quirks() -> ClientQuirks {
323 ClientQuirks::default()
324 }
325
326 #[test]
327 fn internal_tools_never_visible_even_in_power() {
328 let p = ToolProfile::Power;
330 assert!(!is_tool_visible("ctx_metrics", &p, &[], no_quirks(), true));
331 assert!(!is_tool_visible("ctx_cost", &p, &[], no_quirks(), true));
332 assert!(!is_tool_visible(
333 "ctx_discover_tools",
334 &p,
335 &[],
336 no_quirks(),
337 true
338 ));
339 }
340
341 #[test]
342 fn deprecated_aliases_never_visible_even_in_power() {
343 let p = ToolProfile::Power;
346 assert!(!is_tool_visible(
347 "ctx_smart_read",
348 &p,
349 &[],
350 no_quirks(),
351 true
352 ));
353 assert!(!is_tool_visible(
354 "ctx_multi_read",
355 &p,
356 &[],
357 no_quirks(),
358 true
359 ));
360 }
361
362 #[test]
363 fn deprecated_aliases_stay_registered_and_callable() {
364 let _guard = crate::core::data_dir::isolated_data_dir();
368 let defs = crate::server::registry::build_registry().tool_defs();
369 for name in [
370 "ctx_smart_read",
371 "ctx_multi_read",
372 "ctx_semantic_search",
373 "ctx_symbol",
374 ] {
375 assert!(
376 defs.iter().any(|t| t.name.as_ref() == name),
377 "{name} must stay registered (callable) even though hidden"
378 );
379 assert!(
380 !is_tool_visible(name, &ToolProfile::Power, &[], no_quirks(), true),
381 "{name} must be hidden from tools/list"
382 );
383 }
384 }
385
386 #[test]
387 fn core_tool_visible_under_power() {
388 assert!(is_tool_visible(
389 "ctx_read",
390 &ToolProfile::Power,
391 &[],
392 no_quirks(),
393 true
394 ));
395 }
396
397 #[test]
398 fn standard_exposes_its_advertised_tools() {
399 let p = ToolProfile::Standard;
403 assert!(is_tool_visible("ctx_execute", &p, &[], no_quirks(), true));
404 assert!(is_tool_visible("ctx_explore", &p, &[], no_quirks(), true));
405 assert!(is_tool_visible("ctx_callgraph", &p, &[], no_quirks(), true));
406 assert!(is_tool_visible("ctx_graph", &p, &[], no_quirks(), true));
407 assert!(is_tool_visible("ctx_patch", &p, &[], no_quirks(), true));
409 }
410
411 #[test]
412 fn folded_search_aliases_never_visible() {
413 let p = ToolProfile::Power;
416 assert!(!is_tool_visible(
417 "ctx_semantic_search",
418 &p,
419 &[],
420 no_quirks(),
421 true
422 ));
423 assert!(!is_tool_visible("ctx_symbol", &p, &[], no_quirks(), true));
424 assert!(is_tool_visible("ctx_search", &p, &[], no_quirks(), true));
425 }
426
427 #[test]
428 fn minimal_hides_non_minimal_tools() {
429 let p = ToolProfile::Minimal;
430 assert!(is_tool_visible("ctx_read", &p, &[], no_quirks(), true));
431 assert!(!is_tool_visible(
432 "ctx_architecture",
433 &p,
434 &[],
435 no_quirks(),
436 true
437 ));
438 }
439
440 #[test]
441 fn disabled_list_filters() {
442 let disabled = vec!["ctx_read".to_string()];
443 assert!(!is_tool_visible(
444 "ctx_read",
445 &ToolProfile::Power,
446 &disabled,
447 no_quirks(),
448 true
449 ));
450 }
451
452 #[test]
453 fn zed_hides_ctx_edit_only() {
454 let p = ToolProfile::Power;
455 let zed = ClientQuirks {
456 hide_ctx_edit: true,
457 hide_ctx_patch: false,
458 };
459 assert!(!is_tool_visible("ctx_edit", &p, &[], zed, true));
460 assert!(is_tool_visible("ctx_read", &p, &[], zed, true));
461 }
462
463 #[test]
464 fn native_editor_quirk_hides_ctx_patch_only() {
465 let p = ToolProfile::Power;
468 let native = ClientQuirks {
469 hide_ctx_edit: false,
470 hide_ctx_patch: true,
471 };
472 assert!(!is_tool_visible("ctx_patch", &p, &[], native, true));
473 assert!(is_tool_visible("ctx_read", &p, &[], native, true));
474 assert!(is_tool_visible("ctx_edit", &p, &[], native, true));
475 }
476
477 #[test]
478 fn quirks_resolution_is_client_and_candidate_aware() {
479 for client in ["Cursor", "zed 0.164", "Windsurf", "antigravity", "opencode"] {
481 let q = ClientQuirks::resolve(client, CandidateSet::LazyCore);
482 assert!(q.hide_ctx_patch, "{client}: lazy core must hide ctx_patch");
483 }
484 for client in ["claude-code", "CodeBuddy", "pi", "", "my-sdk-harness"] {
487 let q = ClientQuirks::resolve(client, CandidateSet::LazyCore);
488 assert!(
489 !q.hide_ctx_patch,
490 "{client:?}: lazy core must show ctx_patch"
491 );
492 }
493 for candidate in [
495 CandidateSet::ProfileAuthoritative,
496 CandidateSet::Full,
497 CandidateSet::Unified,
498 ] {
499 let q = ClientQuirks::resolve("Cursor", candidate);
500 assert!(
501 !q.hide_ctx_patch,
502 "{candidate:?}: pinned/full surfaces are client-agnostic"
503 );
504 }
505 assert!(ClientQuirks::resolve("zed", CandidateSet::Full).hide_ctx_edit);
507 assert!(!ClientQuirks::resolve("Cursor", CandidateSet::Full).hide_ctx_edit);
508 }
509
510 #[test]
511 fn role_block_hides_tool() {
512 assert!(!is_tool_visible(
513 "ctx_read",
514 &ToolProfile::Power,
515 &[],
516 no_quirks(),
517 false
518 ));
519 }
520
521 #[test]
522 fn category_gate_only_in_default_lean_mode() {
523 assert!(category_gate_applies(true, false));
526 assert!(!category_gate_applies(true, true));
528 assert!(!category_gate_applies(false, false));
530 assert!(!category_gate_applies(false, true));
531 }
532
533 #[test]
534 fn invoker_added_when_missing_in_lazy_mode() {
535 assert!(needs_invoker(false, false, true, &[]));
536 }
537
538 #[test]
539 fn invoker_not_added_in_full_mode() {
540 assert!(!needs_invoker(true, false, true, &[]));
541 }
542
543 #[test]
544 fn invoker_not_duplicated_when_present() {
545 assert!(!needs_invoker(false, true, true, &[]));
546 }
547
548 #[test]
549 fn invoker_respects_role_and_disabled() {
550 assert!(!needs_invoker(false, false, false, &[]));
551 assert!(!needs_invoker(
552 false,
553 false,
554 true,
555 &["ctx_call".to_string()]
556 ));
557 }
558
559 #[test]
621 fn core_tool_surface_stays_within_budget() {
622 const PER_TOOL_BUDGET: usize = 410;
623 const TOTAL_BUDGET: usize = 3000;
624
625 let _guard = crate::core::data_dir::isolated_data_dir();
626 let core = crate::tool_defs::core_tool_names();
627 let defs: Vec<_> = crate::server::registry::build_registry()
628 .tool_defs()
629 .into_iter()
630 .filter(|t| core.contains(&t.name.as_ref()))
631 .collect();
632 assert_eq!(defs.len(), core.len(), "every core tool must be registered");
633
634 let mut total = 0usize;
635 for t in &defs {
636 let desc = t.description.as_deref().unwrap_or("");
637 let schema = serde_json::to_string(&t.input_schema).unwrap_or_default();
638 let cost = crate::core::tokens::count_tokens(desc)
639 + crate::core::tokens::count_tokens(&schema);
640 eprintln!("{:24} {cost:4} tok", t.name.as_ref());
641 assert!(
642 cost <= PER_TOOL_BUDGET,
643 "{} costs {cost} tok (budget {PER_TOOL_BUDGET}) — trim its description/schema",
644 t.name
645 );
646 total += cost;
647 }
648 eprintln!("CORE TOTAL: {total} tok / {} tools", defs.len());
649 assert!(
650 total <= TOTAL_BUDGET,
651 "core surface costs {total} tok (budget {TOTAL_BUDGET})"
652 );
653 }
654
655 #[test]
656 fn resolve_auto_returns_non_auto_unchanged() {
657 assert_eq!(
658 resolve_auto_profile(&ToolProfile::Power),
659 ToolProfile::Power
660 );
661 assert_eq!(
662 resolve_auto_profile(&ToolProfile::Minimal),
663 ToolProfile::Minimal
664 );
665 }
666
667 #[test]
668 fn resolve_auto_resolves_to_concrete_profile() {
669 let resolved = resolve_auto_profile(&ToolProfile::Auto);
670 assert_ne!(resolved, ToolProfile::Auto);
671 }
672
673 #[test]
676 fn shadow_only_candidate_when_hook_covered() {
677 let c = candidate_set(&CandidateInputs {
679 full_mode: false,
680 unified_env: false,
681 explicit_profile: false,
682 hook_covered: true,
683 });
684 assert_eq!(c, CandidateSet::ShadowOnly);
685 }
686
687 #[test]
688 fn shadow_only_overridden_by_full_mode() {
689 let c = candidate_set(&CandidateInputs {
690 full_mode: true,
691 unified_env: false,
692 explicit_profile: false,
693 hook_covered: true,
694 });
695 assert_eq!(
696 c,
697 CandidateSet::Full,
698 "LEAN_CTX_FULL_TOOLS=1 must override shadow-only"
699 );
700 }
701
702 #[test]
703 fn shadow_only_overridden_by_explicit_profile() {
704 let c = candidate_set(&CandidateInputs {
705 full_mode: false,
706 unified_env: false,
707 explicit_profile: true,
708 hook_covered: true,
709 });
710 assert_eq!(
711 c,
712 CandidateSet::ProfileAuthoritative,
713 "explicit profile must override shadow-only"
714 );
715 }
716
717 #[test]
718 fn lazy_core_when_not_hook_covered() {
719 let c = candidate_set(&CandidateInputs {
720 full_mode: false,
721 unified_env: false,
722 explicit_profile: false,
723 hook_covered: false,
724 });
725 assert_eq!(
726 c,
727 CandidateSet::LazyCore,
728 "non-hook client must get LazyCore"
729 );
730 }
731
732 #[test]
733 fn shadow_only_surface_stays_within_budget() {
734 const SHADOW_BUDGET: usize = 200;
735
736 let _guard = crate::core::data_dir::isolated_data_dir();
737 let defs: Vec<_> = crate::server::registry::build_registry()
738 .tool_defs()
739 .into_iter()
740 .filter(|t| t.name.as_ref() == INVOKER)
741 .collect();
742 assert_eq!(
743 defs.len(),
744 1,
745 "shadow-only pool must contain exactly ctx_call"
746 );
747 assert_eq!(defs[0].name.as_ref(), "ctx_call");
748
749 let desc = defs[0].description.as_deref().unwrap_or("");
750 let schema = serde_json::to_string(&defs[0].input_schema).unwrap_or_default();
751 let cost =
752 crate::core::tokens::count_tokens(desc) + crate::core::tokens::count_tokens(&schema);
753 eprintln!("SHADOW-ONLY: ctx_call = {cost} tok (budget {SHADOW_BUDGET})");
754 assert!(
755 cost <= SHADOW_BUDGET,
756 "ctx_call costs {cost} tok (shadow budget {SHADOW_BUDGET})"
757 );
758 }
759}