lean_ctx/server/tool_visibility.rs
1//! Pure tool-visibility policy for the MCP `tools/list` response.
2//!
3//! Extracted from the (async, server-bound) `list_tools` handler so the policy
4//! is unit-testable in isolation. The handler resolves the candidate set
5//! (lazy-core vs profile-authoritative vs full registry) and the per-call gates
6//! (role, workflow), then defers to these helpers for the stable rules:
7//! * Internal/meta tools are never advertised.
8//! * The active profile, `disabled_tools`, and the per-client
9//! [`ClientQuirks`] (Zed `ctx_edit`, native-editor `ctx_patch`) filter the
10//! candidates.
11//! * The universal invoker (`ctx_call`) is force-advertised in non-full mode so
12//! tools hidden by lazy/profile filtering stay reachable.
13
14use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
15
16use super::dynamic_tools::{ToolCategory, categorize_tool};
17use crate::core::tool_profiles::ToolProfile;
18
19// ── Auto-profile session signals ─────────────────────────────
20
21static 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
25/// Increment the Auto-profile turn counter (call once per tools/list request).
26pub fn record_auto_turn() {
27 AUTO_TURN_COUNT.fetch_add(1, Ordering::Relaxed);
28}
29
30/// Mark that the agent has invoked a ctx_* MCP tool in this session.
31pub fn mark_auto_ctx_tool_used() {
32 AUTO_CTX_TOOLS_USED.store(true, Ordering::Relaxed);
33}
34
35/// Update the system prompt token estimate for Auto-profile resolution.
36#[allow(dead_code)]
37pub fn set_auto_system_prompt_tokens(tokens: usize) {
38 AUTO_SYSTEM_PROMPT_TOKENS.store(tokens, Ordering::Relaxed);
39}
40
41/// Resolve `ToolProfile::Auto` to a concrete profile using session signals.
42/// Non-Auto profiles are returned as-is.
43#[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
55/// The universal invoker tool name. A static-list MCP client can call any
56/// registered tool through it, even when that tool isn't advertised.
57pub const INVOKER: &str = "ctx_call";
58
59/// Which candidate pool `tools/list` starts from, before per-tool gates run.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum CandidateSet {
62 /// Full registry (`LEAN_CTX_FULL_TOOLS=1` / `LEAN_CTX_LAZY_TOOLS=0`).
63 Full,
64 /// Consolidated unified surface (`LEAN_CTX_UNIFIED`).
65 Unified,
66 /// The user pinned a profile — it is authoritative and resolves against
67 /// the full registry (#358), so `standard` advertises its complete set.
68 ProfileAuthoritative,
69 /// Lean default: only `CORE_TOOL_NAMES` are advertised; everything else
70 /// stays reachable through [`INVOKER`] (#575).
71 LazyCore,
72}
73
74/// Decides the candidate pool. Single source of truth for the `tools/list`
75/// handler AND offline measurement (`doctor overhead`), so the advertised
76/// surface and the reported overhead can never drift apart.
77#[must_use]
78pub fn candidate_set(full_mode: bool, unified_env: bool, explicit_profile: bool) -> CandidateSet {
79 if full_mode {
80 CandidateSet::Full
81 } else if unified_env {
82 CandidateSet::Unified
83 } else if explicit_profile {
84 CandidateSet::ProfileAuthoritative
85 } else {
86 CandidateSet::LazyCore
87 }
88}
89
90/// Whether the user explicitly pinned a tool profile (config key, custom tool
91/// list, or env var) — the trigger for [`CandidateSet::ProfileAuthoritative`].
92#[must_use]
93pub fn explicit_profile(cfg: &crate::core::config::Config) -> bool {
94 cfg.tool_profile.is_some()
95 || !cfg.tools_enabled.is_empty()
96 || std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok()
97}
98
99/// Client-specific advertising quirks, resolved once per `tools/list` from the
100/// MCP `clientInfo` name and the candidate set.
101///
102/// [`ClientQuirks::default`] (no quirks) is the "default client" used by
103/// offline measurement — the worst-case surface, nothing hidden.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
105pub struct ClientQuirks {
106 /// Zed cannot handle `ctx_edit` (schema quirk) — hide it there.
107 pub hide_ctx_edit: bool,
108 /// Lazy-core only (#1008): the client ships a reliable native str-replace
109 /// editor, so the *default* surface skips `ctx_patch` — those sessions pay
110 /// zero extra schema tokens. A pinned profile is the user's explicit,
111 /// client-agnostic choice and always advertises its full set.
112 pub hide_ctx_patch: bool,
113}
114
115impl ClientQuirks {
116 /// Resolve the quirks for one `tools/list` answer.
117 #[must_use]
118 pub fn resolve(client_name: &str, candidate: CandidateSet) -> Self {
119 let lower = client_name.to_lowercase();
120 Self {
121 hide_ctx_edit: lower.contains("zed"),
122 hide_ctx_patch: candidate == CandidateSet::LazyCore && has_native_editor(&lower),
123 }
124 }
125}
126
127/// Clients whose built-in edit tool is reliable enough that the default
128/// (lazy-core) surface need not advertise `ctx_patch`: Cursor, Zed,
129/// Windsurf/Codeium, Antigravity, OpenCode. Everyone else gets the anchored
130/// editor — Claude Code (the hook read-redirect breaks its native
131/// read-before-write guard, #637), CodeBuddy, pi/SDK harnesses and
132/// unknown/headless clients that have no native editor at all.
133fn has_native_editor(lower_client_name: &str) -> bool {
134 [
135 "cursor",
136 "zed",
137 "windsurf",
138 "codeium",
139 "antigravity",
140 "opencode",
141 ]
142 .iter()
143 .any(|c| lower_client_name.contains(c))
144}
145
146/// Decides whether a tool name should appear in `tools/list`.
147///
148/// `role_allows` is supplied by the caller (it depends on the active role, which
149/// is resolved outside this pure function). Internal tools are hidden
150/// unconditionally — they're invoked automatically or via [`INVOKER`].
151#[must_use]
152pub fn is_tool_visible(
153 name: &str,
154 profile: &ToolProfile,
155 disabled: &[String],
156 quirks: ClientQuirks,
157 role_allows: bool,
158) -> bool {
159 if categorize_tool(name) == ToolCategory::Internal {
160 return false;
161 }
162 // #509: deprecated read-cluster aliases (ctx_smart_read, ctx_multi_read) are
163 // hidden from the advertised surface but stay callable for one release.
164 if super::dynamic_tools::is_deprecated_alias(name) {
165 return false;
166 }
167 if !profile.is_tool_enabled(name) {
168 return false;
169 }
170 if disabled.iter().any(|d| d == name) {
171 return false;
172 }
173 if quirks.hide_ctx_edit && name == "ctx_edit" {
174 return false;
175 }
176 if quirks.hide_ctx_patch && name == "ctx_patch" {
177 return false;
178 }
179 role_allows
180}
181
182/// Computes the tool set this install advertises to a default client
183/// (no client quirks, no role restriction, no workflow gate, static tool list),
184/// including the live description compression. Offline counterpart of the
185/// `tools/list` handler for `doctor overhead` / `ContextOverhead::measure` —
186/// kept next to the pure gates so measurement cannot drift from policy.
187/// "No quirks" is the worst case: a client without a native editor sees
188/// `ctx_patch` too, so the reported overhead never understates.
189#[must_use]
190pub fn advertised_tool_defs_default() -> Vec<rmcp::model::Tool> {
191 let cfg = crate::core::config::Config::load();
192 let disabled = cfg.disabled_tools_effective();
193 let profile = cfg.tool_profile_effective();
194 let full_mode = crate::tool_defs::is_full_mode();
195 let registry = crate::server::registry::build_registry();
196
197 let candidate = candidate_set(
198 full_mode,
199 std::env::var("LEAN_CTX_UNIFIED").is_ok(),
200 explicit_profile(&cfg),
201 );
202 let pool: Vec<rmcp::model::Tool> = match candidate {
203 CandidateSet::Full | CandidateSet::ProfileAuthoritative => registry.tool_defs(),
204 CandidateSet::Unified => crate::tool_defs::unified_tool_defs(),
205 CandidateSet::LazyCore => {
206 let core = crate::tool_defs::core_tool_names();
207 registry
208 .tool_defs()
209 .into_iter()
210 .filter(|t| core.contains(&t.name.as_ref()))
211 .collect()
212 }
213 };
214
215 let mut tools: Vec<_> = pool
216 .into_iter()
217 .filter(|t| {
218 is_tool_visible(
219 t.name.as_ref(),
220 &profile,
221 &disabled,
222 ClientQuirks::default(),
223 true,
224 )
225 })
226 .collect();
227
228 let already = tools.iter().any(|t| t.name.as_ref() == INVOKER);
229 if needs_invoker(full_mode, already, true, &disabled)
230 && let Some(def) = registry
231 .tool_defs()
232 .into_iter()
233 .find(|t| t.name.as_ref() == INVOKER)
234 {
235 tools.push(def);
236 }
237
238 let level = crate::core::config::CompressionLevel::effective(&cfg);
239 let mode = crate::core::terse::mcp_compress::DescriptionMode::from_compression_level(&level);
240 if mode == crate::core::terse::mcp_compress::DescriptionMode::Full {
241 return tools;
242 }
243 tools
244 .into_iter()
245 .map(|mut t| {
246 let compressed = crate::core::terse::mcp_compress::compress_description(
247 t.name.as_ref(),
248 t.description.as_deref().unwrap_or(""),
249 mode,
250 );
251 t.description = Some(compressed.into());
252 t
253 })
254 .collect()
255}
256
257/// Whether the lazy per-category gate should filter the advertised tool set.
258///
259/// The dynamic-tools category gate (load tools on demand, signalled via
260/// `notifications/tools/list_changed`) exists to keep the *default* lean-core
261/// surface small for capable clients. An explicit profile is the user's chosen,
262/// authoritative surface, so it must be advertised in full — otherwise category
263/// gating silently drops profile-enabled tools (e.g. Standard's
264/// `ctx_architecture` / `ctx_semantic_search`) for clients like Codex, and the
265/// advertised set stops matching `lean-ctx tools show` (#358).
266#[must_use]
267pub fn category_gate_applies(supports_list_changed: bool, explicit_profile: bool) -> bool {
268 supports_list_changed && !explicit_profile
269}
270
271/// Whether [`INVOKER`] must be force-added to the advertised set.
272///
273/// True only in non-full mode when it isn't already present, the role permits
274/// it, and it isn't explicitly disabled. In full mode every tool is already
275/// listed, so no gateway is needed.
276#[must_use]
277pub fn needs_invoker(
278 full_mode: bool,
279 already_present: bool,
280 invoker_role_allowed: bool,
281 disabled: &[String],
282) -> bool {
283 !full_mode && !already_present && invoker_role_allowed && !disabled.iter().any(|d| d == INVOKER)
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 /// No client quirks — the default/measurement client.
291 fn no_quirks() -> ClientQuirks {
292 ClientQuirks::default()
293 }
294
295 #[test]
296 fn internal_tools_never_visible_even_in_power() {
297 // Power enables everything, but Internal/meta tools must still be hidden.
298 let p = ToolProfile::Power;
299 assert!(!is_tool_visible("ctx_metrics", &p, &[], no_quirks(), true));
300 assert!(!is_tool_visible("ctx_cost", &p, &[], no_quirks(), true));
301 assert!(!is_tool_visible(
302 "ctx_discover_tools",
303 &p,
304 &[],
305 no_quirks(),
306 true
307 ));
308 }
309
310 #[test]
311 fn deprecated_aliases_never_visible_even_in_power() {
312 // #509: folded read-cluster aliases are hidden from tools/list in every
313 // mode (Power enables everything) — but stay registered + callable.
314 let p = ToolProfile::Power;
315 assert!(!is_tool_visible(
316 "ctx_smart_read",
317 &p,
318 &[],
319 no_quirks(),
320 true
321 ));
322 assert!(!is_tool_visible(
323 "ctx_multi_read",
324 &p,
325 &[],
326 no_quirks(),
327 true
328 ));
329 }
330
331 #[test]
332 fn deprecated_aliases_stay_registered_and_callable() {
333 // The non-breaking contract (#509): hidden from the advertised surface,
334 // but still in the registry so direct calls and ctx_call keep working
335 // for one release. Removal is Phase 2.
336 let _guard = crate::core::data_dir::isolated_data_dir();
337 let defs = crate::server::registry::build_registry().tool_defs();
338 for name in [
339 "ctx_smart_read",
340 "ctx_multi_read",
341 "ctx_semantic_search",
342 "ctx_symbol",
343 ] {
344 assert!(
345 defs.iter().any(|t| t.name.as_ref() == name),
346 "{name} must stay registered (callable) even though hidden"
347 );
348 assert!(
349 !is_tool_visible(name, &ToolProfile::Power, &[], no_quirks(), true),
350 "{name} must be hidden from tools/list"
351 );
352 }
353 }
354
355 #[test]
356 fn core_tool_visible_under_power() {
357 assert!(is_tool_visible(
358 "ctx_read",
359 &ToolProfile::Power,
360 &[],
361 no_quirks(),
362 true
363 ));
364 }
365
366 #[test]
367 fn standard_exposes_its_advertised_tools() {
368 // These are in STANDARD_TOOLS but were dropped by the old
369 // `core ∩ standard` intersection. Profile-authoritative resolution must
370 // surface them.
371 let p = ToolProfile::Standard;
372 assert!(is_tool_visible("ctx_execute", &p, &[], no_quirks(), true));
373 assert!(is_tool_visible("ctx_explore", &p, &[], no_quirks(), true));
374 assert!(is_tool_visible("ctx_callgraph", &p, &[], no_quirks(), true));
375 assert!(is_tool_visible("ctx_graph", &p, &[], no_quirks(), true));
376 // #1008: anchored editing ships with the pinned Standard profile.
377 assert!(is_tool_visible("ctx_patch", &p, &[], no_quirks(), true));
378 }
379
380 #[test]
381 fn folded_search_aliases_never_visible() {
382 // #509: ctx_semantic_search + ctx_symbol are consolidated into ctx_search
383 // (action=…). Hidden from tools/list in every mode, but stay callable.
384 let p = ToolProfile::Power;
385 assert!(!is_tool_visible(
386 "ctx_semantic_search",
387 &p,
388 &[],
389 no_quirks(),
390 true
391 ));
392 assert!(!is_tool_visible("ctx_symbol", &p, &[], no_quirks(), true));
393 assert!(is_tool_visible("ctx_search", &p, &[], no_quirks(), true));
394 }
395
396 #[test]
397 fn minimal_hides_non_minimal_tools() {
398 let p = ToolProfile::Minimal;
399 assert!(is_tool_visible("ctx_read", &p, &[], no_quirks(), true));
400 assert!(!is_tool_visible(
401 "ctx_architecture",
402 &p,
403 &[],
404 no_quirks(),
405 true
406 ));
407 }
408
409 #[test]
410 fn disabled_list_filters() {
411 let disabled = vec!["ctx_read".to_string()];
412 assert!(!is_tool_visible(
413 "ctx_read",
414 &ToolProfile::Power,
415 &disabled,
416 no_quirks(),
417 true
418 ));
419 }
420
421 #[test]
422 fn zed_hides_ctx_edit_only() {
423 let p = ToolProfile::Power;
424 let zed = ClientQuirks {
425 hide_ctx_edit: true,
426 hide_ctx_patch: false,
427 };
428 assert!(!is_tool_visible("ctx_edit", &p, &[], zed, true));
429 assert!(is_tool_visible("ctx_read", &p, &[], zed, true));
430 }
431
432 #[test]
433 fn native_editor_quirk_hides_ctx_patch_only() {
434 // #1008: a native-editor client in the lazy default drops ctx_patch —
435 // and nothing else.
436 let p = ToolProfile::Power;
437 let native = ClientQuirks {
438 hide_ctx_edit: false,
439 hide_ctx_patch: true,
440 };
441 assert!(!is_tool_visible("ctx_patch", &p, &[], native, true));
442 assert!(is_tool_visible("ctx_read", &p, &[], native, true));
443 assert!(is_tool_visible("ctx_edit", &p, &[], native, true));
444 }
445
446 #[test]
447 fn quirks_resolution_is_client_and_candidate_aware() {
448 // Native-editor clients skip ctx_patch in the lazy default…
449 for client in ["Cursor", "zed 0.164", "Windsurf", "antigravity", "opencode"] {
450 let q = ClientQuirks::resolve(client, CandidateSet::LazyCore);
451 assert!(q.hide_ctx_patch, "{client}: lazy core must hide ctx_patch");
452 }
453 // …clients without a reliable native editor get it (#637: Claude Code's
454 // read-before-write guard breaks under the read-redirect hook).
455 for client in ["claude-code", "CodeBuddy", "pi", "", "my-sdk-harness"] {
456 let q = ClientQuirks::resolve(client, CandidateSet::LazyCore);
457 assert!(
458 !q.hide_ctx_patch,
459 "{client:?}: lazy core must show ctx_patch"
460 );
461 }
462 // A pinned profile is client-agnostic — never hide ctx_patch there.
463 for candidate in [
464 CandidateSet::ProfileAuthoritative,
465 CandidateSet::Full,
466 CandidateSet::Unified,
467 ] {
468 let q = ClientQuirks::resolve("Cursor", candidate);
469 assert!(
470 !q.hide_ctx_patch,
471 "{candidate:?}: pinned/full surfaces are client-agnostic"
472 );
473 }
474 // The Zed ctx_edit quirk is independent of the candidate set.
475 assert!(ClientQuirks::resolve("zed", CandidateSet::Full).hide_ctx_edit);
476 assert!(!ClientQuirks::resolve("Cursor", CandidateSet::Full).hide_ctx_edit);
477 }
478
479 #[test]
480 fn role_block_hides_tool() {
481 assert!(!is_tool_visible(
482 "ctx_read",
483 &ToolProfile::Power,
484 &[],
485 no_quirks(),
486 false
487 ));
488 }
489
490 #[test]
491 fn category_gate_only_in_default_lean_mode() {
492 // Lazy gate applies only when the client supports list_changed AND no
493 // explicit profile is set.
494 assert!(category_gate_applies(true, false));
495 // Explicit profile is authoritative — never gated (#358).
496 assert!(!category_gate_applies(true, true));
497 // Static-list clients are never gated regardless of profile.
498 assert!(!category_gate_applies(false, false));
499 assert!(!category_gate_applies(false, true));
500 }
501
502 #[test]
503 fn invoker_added_when_missing_in_lazy_mode() {
504 assert!(needs_invoker(false, false, true, &[]));
505 }
506
507 #[test]
508 fn invoker_not_added_in_full_mode() {
509 assert!(!needs_invoker(true, false, true, &[]));
510 }
511
512 #[test]
513 fn invoker_not_duplicated_when_present() {
514 assert!(!needs_invoker(false, true, true, &[]));
515 }
516
517 #[test]
518 fn invoker_respects_role_and_disabled() {
519 assert!(!needs_invoker(false, false, false, &[]));
520 assert!(!needs_invoker(
521 false,
522 false,
523 true,
524 &["ctx_call".to_string()]
525 ));
526 }
527
528 /// #576 schema diet: the lazy-core surface is the default fixed cost every
529 /// session pays — keep it bounded. Per-tool cap keeps any single schema
530 /// from bloating; the total cap keeps the whole advertised surface lean.
531 /// (Raw registry defs, before description compression — worst case.)
532 ///
533 /// The total grew with the 14th core tool, `ctx_semantic_search` (#422):
534 /// it joined the lean core so agents discover semantic search by default
535 /// instead of never reaching for it. The per-tool cap (300) still guards
536 /// individual bloat; the total budget is sized to that 14-tool surface.
537 ///
538 /// Bumped to 2260 for #432: `ctx_read` now advertises the `offset`/`limit`
539 /// aliases (so agents trained on the native Read tool discover them), a
540 /// deliberate +~32 tok. Descriptions are kept terse to limit the cost.
541 ///
542 /// Bumped to 2275 for #451: `ctx_shell` now states it runs the system shell
543 /// profile-free (no rc/profile sourced), a deliberate +~13 tok so agents stop
544 /// mistaking it for a config-loaded interactive bash. Kept to one terse clause.
545 ///
546 /// Bumped to per-tool 335 / total 2310 for #513: `ctx_read` now documents the
547 /// verbatim escape hatch (`raw=true` arg + `raw` mode) so agents — especially
548 /// non-Opus models that fought the compression — discover how to get exact
549 /// bytes for review/audit instead of guessing. `ctx_read` is the richest core
550 /// tool and is the only one that crosses 300; the per-tool cap still guards
551 /// every other tool from bloat. Kept to terse clauses (+~33 tok on ctx_read).
552 ///
553 /// Bumped to per-tool 360 / total 2340 for #509: `ctx_read` absorbs the
554 /// `ctx_multi_read` batch capability via a `paths` array, so two tools collapse
555 /// into one (`ctx_smart_read` + `ctx_multi_read` are now deprecated aliases
556 /// hidden from the surface). The net effect REDUCES the advertised surface; the
557 /// only local cost is +~18 tok on `ctx_read`'s schema for the new `paths` arg.
558 ///
559 /// #509 search consolidation (cont.): `ctx_search` now subsumes semantic
560 /// search + symbol lookup via an `action` enum, so `ctx_semantic_search` left
561 /// the core set (it + `ctx_symbol` are deprecated aliases). `ctx_search` grew
562 /// (~196 → ~318 tok) but the core total DROPPED (~2298 → ~2150, one fewer
563 /// tool), so the budgets were left unchanged with comfortable headroom.
564 ///
565 /// #578 schema diet: redundant per-property descriptions dropped (names +
566 /// enums self-explain), teaching paragraphs tightened, and `ctx_callgraph`
567 /// (~147 tok) replaced `ctx_graph` (~300 tok) in the lazy core so the
568 /// advertised set matches the injected INTENT playbook. Measured ~1685 tok
569 /// → budgets lowered 360→300 per tool, 2340→1780 total. What remains is
570 /// functional teaching (ctx_read mode enum, ctx_search action routing,
571 /// compose-first) — cut below this only with A/B efficacy evidence.
572 ///
573 /// Bumped to 2050 total for #1008: `ctx_patch` (anchored editing, ~263 tok
574 /// after its schema diet) joined the lazy core so the injected "edit after
575 /// reading → ctx_patch" rule points at an advertised tool. This is the
576 /// worst case (no client quirks): clients with a reliable native editor
577 /// (Cursor, Zed, Windsurf, …) skip `ctx_patch` via `ClientQuirks` and stay
578 /// at the previous ~1685-tok surface.
579 ///
580 /// Bumped to 2060 for #870: `ctx_search` gained `exclude`/`exclude_pattern`
581 /// negative filters (+~7 tok on its schema).
582 ///
583 /// Bumped to 370/2500 for #871: `ctx_search` gained `queries` batch mode
584 /// and restored full action descriptions. Tool correctness > token savings —
585 /// incomplete descriptions cause agents to misuse parameters.
586 ///
587 /// Bumped to 410/3000 for #1020: `ctx_patch` gained per-op JSON Schema
588 /// if/then conditionals so required params are discoverable pre-call.
589 #[test]
590 fn core_tool_surface_stays_within_budget() {
591 const PER_TOOL_BUDGET: usize = 410;
592 const TOTAL_BUDGET: usize = 3000;
593
594 let _guard = crate::core::data_dir::isolated_data_dir();
595 let core = crate::tool_defs::core_tool_names();
596 let defs: Vec<_> = crate::server::registry::build_registry()
597 .tool_defs()
598 .into_iter()
599 .filter(|t| core.contains(&t.name.as_ref()))
600 .collect();
601 assert_eq!(defs.len(), core.len(), "every core tool must be registered");
602
603 let mut total = 0usize;
604 for t in &defs {
605 let desc = t.description.as_deref().unwrap_or("");
606 let schema = serde_json::to_string(&t.input_schema).unwrap_or_default();
607 let cost = crate::core::tokens::count_tokens(desc)
608 + crate::core::tokens::count_tokens(&schema);
609 eprintln!("{:24} {cost:4} tok", t.name.as_ref());
610 assert!(
611 cost <= PER_TOOL_BUDGET,
612 "{} costs {cost} tok (budget {PER_TOOL_BUDGET}) — trim its description/schema",
613 t.name
614 );
615 total += cost;
616 }
617 eprintln!("CORE TOTAL: {total} tok / {} tools", defs.len());
618 assert!(
619 total <= TOTAL_BUDGET,
620 "core surface costs {total} tok (budget {TOTAL_BUDGET})"
621 );
622 }
623
624 #[test]
625 fn resolve_auto_returns_non_auto_unchanged() {
626 assert_eq!(
627 resolve_auto_profile(&ToolProfile::Power),
628 ToolProfile::Power
629 );
630 assert_eq!(
631 resolve_auto_profile(&ToolProfile::Minimal),
632 ToolProfile::Minimal
633 );
634 }
635
636 #[test]
637 fn resolve_auto_resolves_to_concrete_profile() {
638 let resolved = resolve_auto_profile(&ToolProfile::Auto);
639 assert_ne!(resolved, ToolProfile::Auto);
640 }
641}