1use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
16
17use super::dynamic_tools::{ToolCategory, categorize_tool};
18use crate::core::tool_profiles::ToolProfile;
19
20static AUTO_TURN_COUNT: AtomicU64 = AtomicU64::new(0);
23static AUTO_CTX_TOOLS_USED: AtomicBool = AtomicBool::new(false);
24static AUTO_SYSTEM_PROMPT_TOKENS: AtomicUsize = AtomicUsize::new(0);
25
26pub fn record_auto_turn() {
28 AUTO_TURN_COUNT.fetch_add(1, Ordering::Relaxed);
29}
30
31pub fn mark_auto_ctx_tool_used() {
33 AUTO_CTX_TOOLS_USED.store(true, Ordering::Relaxed);
34}
35
36#[must_use]
39pub fn resolve_auto_profile(profile: &ToolProfile) -> ToolProfile {
40 if *profile != ToolProfile::Auto {
41 return profile.clone();
42 }
43 ToolProfile::resolve_auto(
44 AUTO_TURN_COUNT.load(Ordering::Relaxed),
45 AUTO_CTX_TOOLS_USED.load(Ordering::Relaxed),
46 AUTO_SYSTEM_PROMPT_TOKENS.load(Ordering::Relaxed),
47 )
48}
49
50pub const INVOKER: &str = "ctx_call";
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum CandidateSet {
57 Full,
59 Unified,
61 ProfileAuthoritative,
64 LazyCore,
67 #[allow(dead_code)]
71 ShadowOnly,
72}
73
74pub struct CandidateInputs {
76 pub full_mode: bool,
77 pub unified_env: bool,
78 pub explicit_profile: bool,
79 pub hook_covered: bool,
80}
81
82#[must_use]
86pub fn candidate_set(inp: &CandidateInputs) -> CandidateSet {
87 if inp.full_mode {
88 CandidateSet::Full
89 } else if inp.unified_env {
90 CandidateSet::Unified
91 } else if inp.explicit_profile {
92 CandidateSet::ProfileAuthoritative
93 } else {
94 CandidateSet::LazyCore
98 }
99}
100
101#[allow(dead_code)]
103fn is_shadow_surface_enabled() -> bool {
104 if let Ok(v) = std::env::var("LEAN_CTX_TOOL_SURFACE") {
105 return v.eq_ignore_ascii_case("shadow") || v.eq_ignore_ascii_case("auto");
106 }
107 let cfg = crate::core::config::Config::load();
108 !matches!(cfg.tool_surface.as_deref(), Some("mcp"))
111}
112
113#[must_use]
116pub fn explicit_profile(cfg: &crate::core::config::Config) -> bool {
117 cfg.tool_profile.is_some()
118 || !cfg.tools_enabled.is_empty()
119 || std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok()
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
128pub struct ClientQuirks {
129 pub hide_ctx_edit: bool,
131 pub hide_ctx_patch: bool,
136}
137
138impl ClientQuirks {
139 #[must_use]
141 pub fn resolve(client_name: &str, candidate: CandidateSet) -> Self {
142 let lower = client_name.to_lowercase();
143 Self {
144 hide_ctx_edit: lower.contains("zed"),
145 hide_ctx_patch: candidate == CandidateSet::LazyCore && has_native_editor(&lower),
146 }
147 }
148}
149
150fn has_native_editor(lower_client_name: &str) -> bool {
157 [
158 "cursor",
159 "zed",
160 "windsurf",
161 "codeium",
162 "antigravity",
163 "opencode",
164 ]
165 .iter()
166 .any(|c| lower_client_name.contains(c))
167}
168
169#[must_use]
175pub fn is_tool_visible(
176 name: &str,
177 profile: &ToolProfile,
178 disabled: &[String],
179 quirks: ClientQuirks,
180 role_allows: bool,
181) -> bool {
182 if categorize_tool(name) == ToolCategory::Internal {
183 return false;
184 }
185 if !super::dynamic_tools::is_publicly_advertised_tool(name) {
188 return false;
189 }
190 if !profile.is_tool_enabled(name) {
191 return false;
192 }
193 if disabled.iter().any(|d| d == name) {
194 return false;
195 }
196 if quirks.hide_ctx_edit && name == "ctx_edit" {
197 return false;
198 }
199 if quirks.hide_ctx_patch && name == "ctx_patch" {
200 return false;
201 }
202 role_allows
203}
204
205#[must_use]
211pub fn guidance_profile(cfg: &crate::core::config::Config, client_name: &str) -> ToolProfile {
212 let candidate = candidate_set(&CandidateInputs {
213 full_mode: crate::tool_defs::is_full_mode(),
214 unified_env: std::env::var("LEAN_CTX_UNIFIED").is_ok(),
215 explicit_profile: explicit_profile(cfg),
216 hook_covered: false, });
218 narrow_to_surface(
219 lazy_core_surface(cfg.tool_profile_effective(), candidate),
220 &cfg.disabled_tools_effective(),
221 ClientQuirks::resolve(client_name, candidate),
222 )
223}
224
225fn lazy_core_surface(profile: ToolProfile, candidate: CandidateSet) -> ToolProfile {
230 if candidate != CandidateSet::LazyCore {
231 return profile;
232 }
233 ToolProfile::Custom(
234 crate::tool_defs::CORE_TOOL_NAMES
235 .iter()
236 .filter(|name| profile.is_tool_enabled(name))
237 .map(|name| (*name).to_string())
238 .collect(),
239 )
240}
241
242#[must_use]
246pub fn narrow_to_surface(
247 profile: ToolProfile,
248 disabled: &[String],
249 quirks: ClientQuirks,
250) -> ToolProfile {
251 let profile = if profile == ToolProfile::Auto {
252 ToolProfile::Minimal
253 } else {
254 profile
255 };
256 let mut hidden: Vec<&str> = disabled.iter().map(String::as_str).collect();
257 if quirks.hide_ctx_edit {
258 hidden.push("ctx_edit");
259 }
260 if quirks.hide_ctx_patch {
261 hidden.push("ctx_patch");
262 }
263 hidden
264 .into_iter()
265 .fold(profile, |p, name| p.without_tool(name))
266}
267
268#[must_use]
276pub fn advertised_tool_defs_default() -> Vec<rmcp::model::Tool> {
277 let cfg = crate::core::config::Config::load();
278 let disabled = cfg.disabled_tools_effective();
279 let profile = cfg.tool_profile_effective();
280 let full_mode = crate::tool_defs::is_full_mode();
281 let catalog = crate::core::tool_catalog::tool_defs();
282
283 let candidate = candidate_set(&CandidateInputs {
284 full_mode,
285 unified_env: std::env::var("LEAN_CTX_UNIFIED").is_ok(),
286 explicit_profile: explicit_profile(&cfg),
287 hook_covered: false, });
289 let pool: Vec<rmcp::model::Tool> = match candidate {
290 CandidateSet::Full | CandidateSet::ProfileAuthoritative => catalog.clone(),
291 CandidateSet::Unified => crate::tool_defs::unified_tool_defs(),
292 CandidateSet::ShadowOnly | CandidateSet::LazyCore => {
294 let core = crate::tool_defs::core_tool_names();
295 catalog
296 .iter()
297 .filter(|t| core.contains(&t.name.as_ref()))
298 .cloned()
299 .collect()
300 }
301 };
302
303 let mut tools: Vec<_> = pool
304 .into_iter()
305 .filter(|t| {
306 is_tool_visible(
307 t.name.as_ref(),
308 &profile,
309 &disabled,
310 ClientQuirks::default(),
311 true,
312 )
313 })
314 .collect();
315
316 let already = tools.iter().any(|t| t.name.as_ref() == INVOKER);
317 if needs_invoker(full_mode, already, true, &disabled)
318 && let Some(def) = catalog.into_iter().find(|t| t.name.as_ref() == INVOKER)
319 {
320 tools.push(def);
321 }
322
323 let level = crate::core::config::CompressionLevel::effective(&cfg);
324 let mode = crate::core::terse::mcp_compress::DescriptionMode::from_compression_level(&level);
325 if mode == crate::core::terse::mcp_compress::DescriptionMode::Full {
326 return tools;
327 }
328 tools
329 .into_iter()
330 .map(|mut t| {
331 let compressed = crate::core::terse::mcp_compress::compress_description(
332 t.name.as_ref(),
333 t.description.as_deref().unwrap_or(""),
334 mode,
335 );
336 t.description = Some(compressed.into());
337 t
338 })
339 .collect()
340}
341
342#[must_use]
352pub fn category_gate_applies(supports_list_changed: bool, explicit_profile: bool) -> bool {
353 supports_list_changed && !explicit_profile
354}
355
356#[must_use]
362pub fn needs_invoker(
363 full_mode: bool,
364 already_present: bool,
365 invoker_role_allowed: bool,
366 disabled: &[String],
367) -> bool {
368 !full_mode && !already_present && invoker_role_allowed && !disabled.iter().any(|d| d == INVOKER)
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374
375 fn no_quirks() -> ClientQuirks {
377 ClientQuirks::default()
378 }
379
380 #[test]
381 fn internal_tools_never_visible_even_in_power() {
382 let p = ToolProfile::Power;
384 assert!(!is_tool_visible("ctx_metrics", &p, &[], no_quirks(), true));
385 assert!(!is_tool_visible("ctx_cost", &p, &[], no_quirks(), true));
386 assert!(!is_tool_visible(
387 "ctx_discover_tools",
388 &p,
389 &[],
390 no_quirks(),
391 true
392 ));
393 }
394
395 #[test]
396 fn core_tool_visible_under_power() {
397 assert!(is_tool_visible(
398 "ctx_read",
399 &ToolProfile::Power,
400 &[],
401 no_quirks(),
402 true
403 ));
404 }
405
406 #[test]
407 fn standard_exposes_its_advertised_tools() {
408 let p = ToolProfile::Standard;
412 assert!(is_tool_visible("ctx_execute", &p, &[], no_quirks(), true));
413 assert!(is_tool_visible("ctx_compose", &p, &[], no_quirks(), true));
414 assert!(is_tool_visible("ctx_callgraph", &p, &[], no_quirks(), true));
415 assert!(is_tool_visible("ctx_graph", &p, &[], no_quirks(), true));
416 assert!(is_tool_visible("ctx_patch", &p, &[], no_quirks(), true));
418 }
419
420 #[test]
421 fn minimal_hides_non_minimal_tools() {
422 let p = ToolProfile::Minimal;
423 assert!(is_tool_visible("ctx_read", &p, &[], no_quirks(), true));
424 assert!(!is_tool_visible("ctx_index", &p, &[], no_quirks(), true));
425 }
426
427 #[test]
428 fn disabled_list_filters() {
429 let disabled = vec!["ctx_read".to_string()];
430 assert!(!is_tool_visible(
431 "ctx_read",
432 &ToolProfile::Power,
433 &disabled,
434 no_quirks(),
435 true
436 ));
437 }
438
439 #[test]
440 fn zed_hides_ctx_edit_only() {
441 let p = ToolProfile::Power;
442 let zed = ClientQuirks {
443 hide_ctx_edit: true,
444 hide_ctx_patch: false,
445 };
446 assert!(!is_tool_visible("ctx_edit", &p, &[], zed, true));
447 assert!(is_tool_visible("ctx_read", &p, &[], zed, true));
448 }
449
450 #[test]
451 fn native_editor_quirk_hides_ctx_patch_only() {
452 let p = ToolProfile::Power;
455 let native = ClientQuirks {
456 hide_ctx_edit: false,
457 hide_ctx_patch: true,
458 };
459 assert!(!is_tool_visible("ctx_patch", &p, &[], native, true));
460 assert!(is_tool_visible("ctx_read", &p, &[], native, true));
461 assert!(is_tool_visible("ctx_edit", &p, &[], native, true));
462 }
463
464 fn guidance_names(profile: &ToolProfile) -> String {
467 use crate::core::config::CompressionLevel;
468 use crate::core::rules_canonical::{Wrapper, render};
469 let mut text = String::new();
470 for shadow in [true, false] {
471 text.push_str(&render(
472 shadow,
473 Wrapper::Bare,
474 CompressionLevel::Off,
475 profile,
476 ));
477 }
478 text
479 }
480
481 #[test]
482 fn disabled_tools_leave_the_guidance() {
483 let disabled = vec!["ctx_callgraph".to_string()];
486 let p = narrow_to_surface(ToolProfile::Power, &disabled, ClientQuirks::default());
487 assert!(!p.is_tool_enabled("ctx_callgraph"));
488 assert!(
489 !is_tool_visible(
490 "ctx_callgraph",
491 &ToolProfile::Power,
492 &disabled,
493 ClientQuirks::default(),
494 true
495 ),
496 "precondition: tools/list hides it"
497 );
498 let text = guidance_names(&p);
499 assert!(
500 !text.contains("ctx_callgraph"),
501 "guidance names a disabled tool:\n{text}"
502 );
503 assert!(text.contains("ctx_patch"));
505 assert!(text.contains("ctx_compose"));
506 }
507
508 #[test]
509 fn lazy_core_guidance_names_only_core_tools() {
510 let core = lazy_core_surface(ToolProfile::Power, CandidateSet::LazyCore);
514 let native = ClientQuirks {
515 hide_ctx_edit: false,
516 hide_ctx_patch: true,
517 };
518 let surface = narrow_to_surface(core, &[], native);
519 let text = guidance_names(&surface);
520 for absent in ["ctx_knowledge", "ctx_patch", "ctx_graph", "ctx_overview"] {
521 assert!(!text.contains(absent), "names {absent}:\n{text}");
522 }
523 for present in ["ctx_compose", "ctx_session", "ctx_callgraph", "ctx_call("] {
524 assert!(text.contains(present), "misses {present}:\n{text}");
525 }
526 assert_eq!(
528 lazy_core_surface(ToolProfile::Power, CandidateSet::ProfileAuthoritative),
529 ToolProfile::Power
530 );
531 }
532
533 #[test]
534 fn a_hidden_ctx_patch_leaves_the_guidance() {
535 let quirk = ClientQuirks {
539 hide_ctx_edit: false,
540 hide_ctx_patch: true,
541 };
542 let by_quirk = narrow_to_surface(ToolProfile::Power, &[], quirk);
543 let disabled = vec!["ctx_edit".to_string(), "ctx_patch".to_string()];
544 let by_config =
545 narrow_to_surface(ToolProfile::Standard, &disabled, ClientQuirks::default());
546 for p in [by_quirk, by_config] {
547 let text = guidance_names(&p);
548 assert!(
549 !text.contains("ctx_patch"),
550 "{p}: guidance names ctx_patch:\n{text}"
551 );
552 assert!(
553 text.contains("the host's native edit tool"),
554 "{p}: edit line lost"
555 );
556 }
557 }
558
559 #[test]
560 fn auto_guidance_starts_at_minimal() {
561 let p = narrow_to_surface(ToolProfile::Auto, &[], ClientQuirks::default());
564 assert_eq!(p, ToolProfile::Minimal);
565 assert!(!guidance_names(&p).contains("ctx_compose"));
566 }
567
568 #[test]
569 fn an_unrestricted_surface_keeps_the_profile() {
570 for p in [
571 ToolProfile::Power,
572 ToolProfile::Standard,
573 ToolProfile::Minimal,
574 ] {
575 assert_eq!(
576 narrow_to_surface(p.clone(), &[], ClientQuirks::default()),
577 p
578 );
579 }
580 }
581
582 #[test]
583 fn quirks_resolution_is_client_and_candidate_aware() {
584 for client in ["Cursor", "zed 0.164", "Windsurf", "antigravity", "opencode"] {
586 let q = ClientQuirks::resolve(client, CandidateSet::LazyCore);
587 assert!(q.hide_ctx_patch, "{client}: lazy core must hide ctx_patch");
588 }
589 for client in ["claude-code", "CodeBuddy", "pi", "", "my-sdk-harness"] {
592 let q = ClientQuirks::resolve(client, CandidateSet::LazyCore);
593 assert!(
594 !q.hide_ctx_patch,
595 "{client:?}: lazy core must show ctx_patch"
596 );
597 }
598 for candidate in [
600 CandidateSet::ProfileAuthoritative,
601 CandidateSet::Full,
602 CandidateSet::Unified,
603 ] {
604 let q = ClientQuirks::resolve("Cursor", candidate);
605 assert!(
606 !q.hide_ctx_patch,
607 "{candidate:?}: pinned/full surfaces are client-agnostic"
608 );
609 }
610 assert!(ClientQuirks::resolve("zed", CandidateSet::Full).hide_ctx_edit);
612 assert!(!ClientQuirks::resolve("Cursor", CandidateSet::Full).hide_ctx_edit);
613 }
614
615 #[test]
616 fn role_block_hides_tool() {
617 assert!(!is_tool_visible(
618 "ctx_read",
619 &ToolProfile::Power,
620 &[],
621 no_quirks(),
622 false
623 ));
624 }
625
626 #[test]
627 fn category_gate_only_in_default_lean_mode() {
628 assert!(category_gate_applies(true, false));
631 assert!(!category_gate_applies(true, true));
633 assert!(!category_gate_applies(false, false));
635 assert!(!category_gate_applies(false, true));
636 }
637
638 #[test]
639 fn invoker_added_when_missing_in_lazy_mode() {
640 assert!(needs_invoker(false, false, true, &[]));
641 }
642
643 #[test]
644 fn invoker_not_added_in_full_mode() {
645 assert!(!needs_invoker(true, false, true, &[]));
646 }
647
648 #[test]
649 fn invoker_not_duplicated_when_present() {
650 assert!(!needs_invoker(false, true, true, &[]));
651 }
652
653 #[test]
654 fn invoker_respects_role_and_disabled() {
655 assert!(!needs_invoker(false, false, false, &[]));
656 assert!(!needs_invoker(
657 false,
658 false,
659 true,
660 &["ctx_call".to_string()]
661 ));
662 }
663
664 #[test]
665 fn resolve_auto_returns_non_auto_unchanged() {
666 assert_eq!(
667 resolve_auto_profile(&ToolProfile::Power),
668 ToolProfile::Power
669 );
670 assert_eq!(
671 resolve_auto_profile(&ToolProfile::Minimal),
672 ToolProfile::Minimal
673 );
674 }
675
676 #[test]
677 fn resolve_auto_resolves_to_concrete_profile() {
678 let resolved = resolve_auto_profile(&ToolProfile::Auto);
679 assert_ne!(resolved, ToolProfile::Auto);
680 }
681
682 #[test]
685 fn hook_covered_gets_lazy_core_not_shadow_only() {
686 let c = candidate_set(&CandidateInputs {
688 full_mode: false,
689 unified_env: false,
690 explicit_profile: false,
691 hook_covered: true,
692 });
693 assert_eq!(c, CandidateSet::LazyCore);
694 }
695
696 #[test]
697 fn shadow_only_overridden_by_full_mode() {
698 let c = candidate_set(&CandidateInputs {
699 full_mode: true,
700 unified_env: false,
701 explicit_profile: false,
702 hook_covered: true,
703 });
704 assert_eq!(
705 c,
706 CandidateSet::Full,
707 "LEAN_CTX_FULL_TOOLS=1 must override shadow-only"
708 );
709 }
710
711 #[test]
712 fn shadow_only_overridden_by_explicit_profile() {
713 let c = candidate_set(&CandidateInputs {
714 full_mode: false,
715 unified_env: false,
716 explicit_profile: true,
717 hook_covered: true,
718 });
719 assert_eq!(
720 c,
721 CandidateSet::ProfileAuthoritative,
722 "explicit profile must override shadow-only"
723 );
724 }
725
726 #[test]
727 fn lazy_core_when_not_hook_covered() {
728 let c = candidate_set(&CandidateInputs {
729 full_mode: false,
730 unified_env: false,
731 explicit_profile: false,
732 hook_covered: false,
733 });
734 assert_eq!(
735 c,
736 CandidateSet::LazyCore,
737 "non-hook client must get LazyCore"
738 );
739 }
740}