1use std::collections::{BTreeMap, BTreeSet};
8
9use bamboo_domain::{
10 canonical_tool_name as canonical_registry_tool_name, resolve_tool_reference_name,
11 CapabilityInvocationPolicy, CapabilityInvocationTarget, CapabilityKind, CapabilityLoadingClass,
12 CapabilityMatch, CapabilitySource, CapabilityStatus, ClassifiedToolIdentity,
13 ClassifiedToolSchema, DiscoverCapabilitiesRequest, DiscoverCapabilitiesResult, ToolSchema,
14 MAX_DISCOVERY_QUERY_CHARS, MAX_DISCOVERY_RESULTS,
15};
16use thiserror::Error;
17
18use crate::{
19 WorkflowCatalogEntry, WorkflowCatalogSnapshot, WorkflowKind, WorkflowSource, WorkflowStatus,
20};
21
22pub const MAX_CAPABILITY_SUMMARY_CHARS: usize = 240;
25
26#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
27pub enum InvocationEligibility {
28 #[default]
30 Any,
31 Explicit,
32 Automatic,
33}
34
35impl InvocationEligibility {
36 fn permits(self, policy: CapabilityInvocationPolicy) -> bool {
37 match self {
38 Self::Any => policy.explicit || policy.automatic,
39 Self::Explicit => policy.explicit,
40 Self::Automatic => policy.automatic,
41 }
42 }
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct CapabilityDiscoveryEligibility {
52 pub disabled_tool_names: BTreeSet<String>,
53 pub allowed_tool_names: Option<BTreeSet<String>>,
57 pub disabled_skill_ids: BTreeSet<String>,
58 pub allowed_skill_ids: Option<BTreeSet<String>>,
60 pub skill_gateway_available: bool,
61 pub workflow_gateway_available: bool,
62 pub skill_invocation: InvocationEligibility,
63 pub workflow_invocation: InvocationEligibility,
64}
65
66impl Default for CapabilityDiscoveryEligibility {
67 fn default() -> Self {
68 Self {
69 disabled_tool_names: BTreeSet::new(),
70 allowed_tool_names: None,
71 disabled_skill_ids: BTreeSet::new(),
72 allowed_skill_ids: None,
73 skill_gateway_available: true,
74 workflow_gateway_available: true,
75 skill_invocation: InvocationEligibility::Any,
76 workflow_invocation: InvocationEligibility::Any,
77 }
78 }
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct ToolCapabilityMetadata {
84 pub canonical_name: String,
85 pub summary: String,
86 pub source: CapabilitySource,
87 pub aliases: Vec<String>,
88 pub available: bool,
89}
90
91pub fn project_tool_capability_metadata(schemas: &[ToolSchema]) -> Vec<ToolCapabilityMetadata> {
94 schemas
95 .iter()
96 .filter_map(|schema| {
97 let identity = ClassifiedToolIdentity::from_schema_name(&schema.function.name)?;
98 if identity.loading_class() == CapabilityLoadingClass::HostOnly {
99 return None;
100 }
101 Some(ToolCapabilityMetadata {
102 source: source_for_tool(identity.execution_name()),
103 aliases: aliases_for_tool(identity.execution_name()),
104 canonical_name: identity.execution_name().to_string(),
105 summary: bounded_summary(&schema.function.description),
106 available: true,
107 })
108 })
109 .collect()
110}
111
112pub fn project_classified_tool_capability_metadata(
116 catalog: &[ClassifiedToolSchema],
117) -> Vec<ToolCapabilityMetadata> {
118 catalog
119 .iter()
120 .filter(|entry| entry.loading_class() != CapabilityLoadingClass::HostOnly)
121 .map(|entry| ToolCapabilityMetadata {
122 source: source_for_tool(entry.execution_name()),
123 aliases: aliases_for_tool(entry.execution_name()),
124 canonical_name: entry.execution_name().to_string(),
125 summary: bounded_summary(&entry.schema().function.description),
126 available: true,
127 })
128 .collect()
129}
130
131#[derive(Debug, Clone)]
132struct IndexedCapability {
133 value: CapabilityMatch,
134 normalized_names: Vec<String>,
135 normalized_summary: String,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
140#[serde(rename_all = "snake_case")]
141pub enum AutomaticSkillOutcome {
142 EmptyQuery,
143 NoMatch,
144 BelowThreshold,
145 Ambiguous,
146 ExactMatch,
147 StrongMatch,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
151pub struct SkillMatchEvidence {
152 pub capability_ref: String,
153 pub source: CapabilitySource,
154 pub revision: Option<u64>,
155 pub exact_identity: bool,
156 pub name_token_hits: usize,
157 pub summary_token_hits: usize,
158 pub strong_match: bool,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
162pub struct AutomaticSkillDecision {
163 pub query_sha256: String,
164 pub outcome: AutomaticSkillOutcome,
165 pub candidates: Vec<SkillMatchEvidence>,
166 pub selected: Option<CapabilityMatch>,
167}
168
169#[derive(Debug, Clone, Default)]
172pub struct CapabilityDiscoveryIndex {
173 candidates: Vec<IndexedCapability>,
174}
175
176impl CapabilityDiscoveryIndex {
177 pub async fn from_resolved_classified_store(
182 tool_catalog: &[ClassifiedToolSchema],
183 store: &crate::SkillStore,
184 eligibility: &CapabilityDiscoveryEligibility,
185 ) -> Self {
186 let (skill_catalog, workflow_catalog) = store.command_catalog_snapshots().await;
187 let mut non_tool_eligibility = eligibility.clone();
188 non_tool_eligibility.disabled_tool_names.clear();
189 non_tool_eligibility.allowed_tool_names = None;
190 Self::from_snapshots(
191 project_classified_tool_capability_metadata(tool_catalog),
192 &skill_catalog,
193 &workflow_catalog,
194 &non_tool_eligibility,
195 )
196 }
197
198 pub async fn from_store(
203 tool_schemas: &[ToolSchema],
204 store: &crate::SkillStore,
205 eligibility: &CapabilityDiscoveryEligibility,
206 ) -> Self {
207 let (skill_catalog, workflow_catalog) = store.command_catalog_snapshots().await;
208 Self::from_snapshots(
209 project_tool_capability_metadata(tool_schemas),
210 &skill_catalog,
211 &workflow_catalog,
212 eligibility,
213 )
214 }
215
216 pub fn from_snapshots(
217 tools: impl IntoIterator<Item = ToolCapabilityMetadata>,
218 skill_catalog: &WorkflowCatalogSnapshot,
219 workflow_catalog: &WorkflowCatalogSnapshot,
220 eligibility: &CapabilityDiscoveryEligibility,
221 ) -> Self {
222 let mut projected_tools = BTreeMap::<String, ToolCapabilityMetadata>::new();
223
224 for mut tool in tools {
225 let Some(identity) = ClassifiedToolIdentity::from_schema_name(&tool.canonical_name)
226 else {
227 continue;
228 };
229 tool.canonical_name = identity.execution_name().to_string();
230 if identity.loading_class() == CapabilityLoadingClass::HostOnly || !tool.available {
231 continue;
232 }
233 let key = tool.canonical_name.clone();
234 tool.summary = bounded_summary(&tool.summary);
235 tool.aliases.sort();
236 tool.aliases.dedup();
237 match projected_tools.get(&key) {
238 Some(existing) if !prefer_tool_projection(&tool, existing) => {}
239 _ => {
240 projected_tools.insert(key, tool);
241 }
242 }
243 }
244
245 let disabled_execution_names = eligibility
246 .disabled_tool_names
247 .iter()
248 .filter_map(|reference| {
249 resolve_tool_reference_name(reference, |name| projected_tools.contains_key(name))
250 })
251 .collect::<BTreeSet<_>>();
252 let allowed_execution_names = eligibility.allowed_tool_names.as_ref().map(|references| {
253 references
254 .iter()
255 .filter_map(|reference| {
256 resolve_tool_reference_name(reference, |name| {
257 projected_tools.contains_key(name)
258 })
259 })
260 .collect::<BTreeSet<_>>()
261 });
262 projected_tools.retain(|name, _| {
263 !disabled_execution_names.contains(name)
264 && allowed_execution_names
265 .as_ref()
266 .is_none_or(|allowed| allowed.contains(name))
267 });
268
269 let mut candidates = projected_tools
270 .into_values()
271 .map(index_tool)
272 .collect::<Vec<_>>();
273
274 if eligibility.skill_gateway_available {
275 candidates.extend(
276 skill_catalog
277 .entries
278 .iter()
279 .filter(|entry| skill_entry_is_eligible(entry, eligibility))
280 .map(|entry| index_catalog_entry(entry, CapabilityKind::Skill)),
281 );
282 }
283
284 if eligibility.workflow_gateway_available {
285 candidates.extend(
286 workflow_catalog
287 .entries
288 .iter()
289 .filter(|entry| workflow_entry_is_eligible(entry, eligibility))
290 .map(|entry| index_catalog_entry(entry, CapabilityKind::Workflow)),
291 );
292 }
293
294 candidates.sort_by(|left, right| {
295 left.value
296 .kind
297 .cmp(&right.value.kind)
298 .then_with(|| left.value.capability_ref.cmp(&right.value.capability_ref))
299 });
300 candidates.dedup_by(|left, right| {
301 left.value.kind == right.value.kind
302 && left.value.capability_ref == right.value.capability_ref
303 });
304
305 Self { candidates }
306 }
307
308 pub fn discover(
309 &self,
310 request: &DiscoverCapabilitiesRequest,
311 ) -> Result<DiscoverCapabilitiesResult, CapabilityDiscoveryError> {
312 let query = request.query.trim().to_string();
313 let query_chars = query.chars().count();
314 if query_chars > MAX_DISCOVERY_QUERY_CHARS {
315 return Err(CapabilityDiscoveryError::QueryTooLong {
316 actual: query_chars,
317 maximum: MAX_DISCOVERY_QUERY_CHARS,
318 });
319 }
320
321 let limit = request.limit.unwrap_or(MAX_DISCOVERY_RESULTS);
322 if limit > MAX_DISCOVERY_RESULTS {
323 return Err(CapabilityDiscoveryError::LimitTooLarge {
324 actual: limit,
325 maximum: MAX_DISCOVERY_RESULTS,
326 });
327 }
328
329 let kinds = match request.kinds.as_ref() {
330 Some(kinds) if kinds.len() > 3 => {
331 return Err(CapabilityDiscoveryError::TooManyKinds {
332 actual: kinds.len(),
333 maximum: 3,
334 });
335 }
336 Some(kinds) => Some(kinds.iter().copied().collect::<BTreeSet<_>>()),
337 None => None,
338 };
339
340 let empty = || DiscoverCapabilitiesResult {
341 query: query.clone(),
342 matches: Vec::new(),
343 };
344 if query.is_empty() || limit == 0 || kinds.as_ref().is_some_and(BTreeSet::is_empty) {
345 return Ok(empty());
346 }
347
348 let normalized_query = normalize_search_text(&query);
349 let query_tokens = search_tokens(&normalized_query);
350 if normalized_query.is_empty() || query_tokens.is_empty() {
351 return Ok(empty());
352 }
353 let tool_query = query
354 .strip_prefix("tool:")
355 .or_else(|| query.strip_prefix("tool/"))
356 .unwrap_or(&query);
357 let resolved_tool_query = resolve_tool_reference_name(tool_query, |name| {
358 self.candidates.iter().any(|candidate| {
359 matches!(
360 &candidate.value.invocation_target,
361 CapabilityInvocationTarget::Tool { name: registered } if registered == name
362 )
363 })
364 });
365
366 let mut ranked = self
367 .candidates
368 .iter()
369 .filter(|candidate| {
370 kinds
371 .as_ref()
372 .is_none_or(|kinds| kinds.contains(&candidate.value.kind))
373 })
374 .filter_map(|candidate| {
375 rank_candidate(
376 candidate,
377 &query,
378 &normalized_query,
379 &query_tokens,
380 resolved_tool_query.as_deref(),
381 None,
382 )
383 .map(|rank| (rank, candidate))
384 })
385 .collect::<Vec<_>>();
386
387 ranked.sort_by(|(left_rank, left), (right_rank, right)| {
388 right_rank
389 .cmp(left_rank)
390 .then_with(|| left.value.kind.cmp(&right.value.kind))
391 .then_with(|| left.value.capability_ref.cmp(&right.value.capability_ref))
392 });
393
394 Ok(DiscoverCapabilitiesResult {
395 query,
396 matches: ranked
397 .into_iter()
398 .take(limit)
399 .map(|(_, candidate)| candidate.value.clone())
400 .collect(),
401 })
402 }
403
404 pub fn discover_unambiguous_automatic_skill(&self, query: &str) -> Option<CapabilityMatch> {
409 self.explain_automatic_skill(query).selected
410 }
411
412 pub fn explain_automatic_skill(&self, query: &str) -> AutomaticSkillDecision {
413 use sha2::{Digest, Sha256};
414 let query = query
415 .trim()
416 .chars()
417 .take(MAX_DISCOVERY_QUERY_CHARS)
418 .collect::<String>();
419 let mut decision = AutomaticSkillDecision {
420 query_sha256: hex::encode(Sha256::digest(query.as_bytes())),
421 outcome: AutomaticSkillOutcome::EmptyQuery,
422 candidates: Vec::new(),
423 selected: None,
424 };
425 let normalized_query = normalize_search_text(&query);
426 let query_tokens = search_tokens(&normalized_query);
427 if normalized_query.is_empty() || query_tokens.is_empty() {
428 return decision;
429 }
430 let mut ranked = self
431 .candidates
432 .iter()
433 .filter(|candidate| candidate.value.kind == CapabilityKind::Skill)
434 .filter_map(|candidate| {
435 let summary = normalize_search_text(&candidate.value.summary);
436 rank_candidate(
437 candidate,
438 &query,
439 &normalized_query,
440 &query_tokens,
441 None,
442 Some(&summary),
443 )
444 .map(|rank| (rank, candidate))
445 })
446 .collect::<Vec<_>>();
447 ranked.sort_by(|(left_rank, left), (right_rank, right)| {
448 right_rank
449 .cmp(left_rank)
450 .then_with(|| left.value.capability_ref.cmp(&right.value.capability_ref))
451 });
452 decision.candidates = ranked
453 .iter()
454 .take(3)
455 .map(|(rank, candidate)| SkillMatchEvidence {
456 capability_ref: candidate.value.capability_ref.clone(),
457 source: candidate.value.source,
458 revision: candidate.value.revision,
459 exact_identity: rank.semantic.exact_identity,
460 name_token_hits: rank.semantic.name_token_hits,
461 summary_token_hits: rank.semantic.summary_token_hits,
462 strong_match: strong_fuzzy_skill_match(candidate, &query_tokens, rank.semantic),
463 })
464 .collect();
465 if ranked.is_empty() {
466 decision.outcome = AutomaticSkillOutcome::NoMatch;
467 return decision;
468 }
469 let exact = ranked
470 .iter()
471 .filter(|(rank, _)| rank.semantic.exact_identity)
472 .collect::<Vec<_>>();
473 if !exact.is_empty() {
474 if exact.len() == 1 {
475 decision.outcome = AutomaticSkillOutcome::ExactMatch;
476 decision.selected = Some(exact[0].1.value.clone());
477 } else {
478 decision.outcome = AutomaticSkillOutcome::Ambiguous;
479 }
480 return decision;
481 }
482 let strong = ranked
483 .iter()
484 .filter(|(rank, candidate)| {
485 strong_fuzzy_skill_match(candidate, &query_tokens, rank.semantic)
486 })
487 .collect::<Vec<_>>();
488 let Some((top_rank, top)) = strong.first() else {
489 decision.outcome = AutomaticSkillOutcome::BelowThreshold;
490 return decision;
491 };
492 if strong
493 .get(1)
494 .is_some_and(|(rank, _)| rank.semantic == top_rank.semantic)
495 {
496 decision.outcome = AutomaticSkillOutcome::Ambiguous;
497 } else {
498 decision.outcome = AutomaticSkillOutcome::StrongMatch;
499 decision.selected = Some(top.value.clone());
500 }
501 decision
502 }
503}
504
505#[derive(Debug, Clone, PartialEq, Eq, Error)]
506pub enum CapabilityDiscoveryError {
507 #[error("discovery query has {actual} characters; maximum is {maximum}")]
508 QueryTooLong { actual: usize, maximum: usize },
509 #[error("discovery limit is {actual}; maximum is {maximum}")]
510 LimitTooLarge { actual: usize, maximum: usize },
511 #[error("discovery kinds has {actual} entries; maximum is {maximum}")]
512 TooManyKinds { actual: usize, maximum: usize },
513}
514
515#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
516struct SemanticSearchRank {
517 exact_registered_identity: bool,
518 exact_identity: bool,
519 name_phrase: bool,
520 name_token_hits: usize,
521 summary_phrase: bool,
522 summary_token_hits: usize,
523}
524
525#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
526struct SearchRank {
527 semantic: SemanticSearchRank,
528 source_precedence: u8,
529}
530
531fn rank_candidate(
532 candidate: &IndexedCapability,
533 raw_query: &str,
534 normalized_query: &str,
535 query_tokens: &BTreeSet<String>,
536 resolved_tool_query: Option<&str>,
537 normalized_summary_override: Option<&str>,
538) -> Option<SearchRank> {
539 let exact_registered_identity = matches!(
540 (&candidate.value.invocation_target, resolved_tool_query),
541 (CapabilityInvocationTarget::Tool { name }, Some(resolved)) if name == resolved
542 );
543 let exact_identity = candidate
544 .normalized_names
545 .iter()
546 .any(|name| name == normalized_query)
547 || candidate
548 .value
549 .capability_ref
550 .eq_ignore_ascii_case(raw_query)
551 || tool_alias_exact_match(candidate, raw_query);
552 let name_phrase = candidate
553 .normalized_names
554 .iter()
555 .any(|name| name.contains(normalized_query));
556 let name_token_hits = candidate
557 .normalized_names
558 .iter()
559 .map(|name| token_hits(name, query_tokens))
560 .max()
561 .unwrap_or_default();
562 let normalized_summary =
563 normalized_summary_override.unwrap_or(candidate.normalized_summary.as_str());
564 let summary_phrase = normalized_summary.contains(normalized_query);
565 let summary_token_hits = token_hits(normalized_summary, query_tokens);
566
567 if !exact_registered_identity
568 && !exact_identity
569 && !name_phrase
570 && name_token_hits == 0
571 && !summary_phrase
572 && summary_token_hits == 0
573 {
574 return None;
575 }
576
577 Some(SearchRank {
578 semantic: SemanticSearchRank {
579 exact_registered_identity,
580 exact_identity,
581 name_phrase,
582 name_token_hits,
583 summary_phrase,
584 summary_token_hits,
585 },
586 source_precedence: source_precedence(candidate.value.source),
587 })
588}
589
590fn strong_fuzzy_skill_match(
591 candidate: &IndexedCapability,
592 query_tokens: &BTreeSet<String>,
593 semantic_rank: SemanticSearchRank,
594) -> bool {
595 let complete_identity_tokens = candidate.normalized_names.iter().any(|name| {
596 let identity_tokens = search_tokens(name);
597 !identity_tokens.is_empty() && identity_tokens.is_subset(query_tokens)
598 });
599 complete_identity_tokens || semantic_rank.summary_token_hits >= 2
600}
601
602fn tool_alias_exact_match(candidate: &IndexedCapability, raw_query: &str) -> bool {
603 if candidate.value.kind != CapabilityKind::Tool {
604 return false;
605 }
606 let query = raw_query
607 .strip_prefix("tool:")
608 .or_else(|| raw_query.strip_prefix("tool/"))
609 .unwrap_or(raw_query);
610 let canonical = canonical_registry_tool_name(query);
611 matches!(
612 &candidate.value.invocation_target,
613 CapabilityInvocationTarget::Tool { name } if name == &canonical
614 )
615}
616
617fn token_hits(haystack: &str, query_tokens: &BTreeSet<String>) -> usize {
618 let haystack_tokens = search_tokens(haystack);
619 query_tokens
620 .iter()
621 .filter(|token| haystack_tokens.contains(*token))
622 .count()
623}
624
625fn index_tool(tool: ToolCapabilityMetadata) -> IndexedCapability {
626 let capability_ref = format!("tool:{}", tool.canonical_name);
627 let mut normalized_names = vec![
628 normalize_search_text(&capability_ref),
629 normalize_search_text(&tool.canonical_name),
630 ];
631 normalized_names.extend(
632 tool.aliases
633 .iter()
634 .map(|alias| normalize_search_text(alias)),
635 );
636 normalized_names.sort();
637 normalized_names.dedup();
638
639 IndexedCapability {
640 normalized_summary: normalize_search_text(&tool.summary),
641 value: CapabilityMatch {
642 capability_ref,
643 kind: CapabilityKind::Tool,
644 display_name: tool.canonical_name.clone(),
645 summary: tool.summary,
646 source: tool.source,
647 revision: None,
648 status: CapabilityStatus::Available,
649 invocation_policy: None,
650 invocation_target: CapabilityInvocationTarget::Tool {
651 name: tool.canonical_name,
652 },
653 },
654 normalized_names,
655 }
656}
657
658fn index_catalog_entry(entry: &WorkflowCatalogEntry, kind: CapabilityKind) -> IndexedCapability {
659 let source = capability_source(entry.source);
660 let summary = bounded_summary(&entry.description);
661 let capability_ref = match kind {
662 CapabilityKind::Skill => format!("skill:{}", entry.id),
663 CapabilityKind::Workflow => format!("workflow:{}", entry.id),
664 CapabilityKind::Tool => unreachable!("catalog entries are never tool candidates"),
665 };
666 let policy = invocation_policy(entry);
667 let invocation_target = match kind {
668 CapabilityKind::Skill => CapabilityInvocationTarget::Skill {
669 name: "load_skill".to_string(),
670 skill_id: entry.id.clone(),
671 source,
672 revision: entry.revision,
673 },
674 CapabilityKind::Workflow => CapabilityInvocationTarget::Workflow {
675 name: "workflow_run".to_string(),
676 workflow_id: entry.id.clone(),
677 source,
678 revision: entry.revision,
679 },
680 CapabilityKind::Tool => unreachable!("catalog entries are never tool candidates"),
681 };
682
683 let mut policy_hints = Vec::new();
684 if policy.explicit {
685 policy_hints.push("explicit");
686 }
687 if policy.automatic {
688 policy_hints.push("automatic");
689 }
690 let policy_hint = policy_hints.join(" ");
691 IndexedCapability {
692 normalized_names: vec![
693 normalize_search_text(&capability_ref),
694 normalize_search_text(&entry.id),
695 normalize_search_text(&entry.name),
696 ],
697 normalized_summary: normalize_search_text(&format!("{summary} {policy_hint}")),
698 value: CapabilityMatch {
699 capability_ref,
700 kind,
701 display_name: entry.name.clone(),
702 summary,
703 source,
704 revision: Some(entry.revision),
705 status: CapabilityStatus::Valid,
706 invocation_policy: Some(policy),
707 invocation_target,
708 },
709 }
710}
711
712fn skill_entry_is_eligible(
713 entry: &WorkflowCatalogEntry,
714 eligibility: &CapabilityDiscoveryEligibility,
715) -> bool {
716 catalog_entry_is_valid(entry)
717 && entry.kind == WorkflowKind::Instruction
718 && !entry.legacy
719 && !eligibility.disabled_skill_ids.contains(&entry.id)
720 && eligibility
721 .allowed_skill_ids
722 .as_ref()
723 .is_none_or(|ids| ids.contains(&entry.id))
724 && eligibility
725 .skill_invocation
726 .permits(invocation_policy(entry))
727}
728
729fn workflow_entry_is_eligible(
730 entry: &WorkflowCatalogEntry,
731 eligibility: &CapabilityDiscoveryEligibility,
732) -> bool {
733 catalog_entry_is_valid(entry)
734 && entry.kind == WorkflowKind::Orchestration
735 && entry.is_public_workflow()
736 && !eligibility.disabled_skill_ids.contains(&entry.id)
737 && eligibility
738 .workflow_invocation
739 .permits(invocation_policy(entry))
740}
741
742fn catalog_entry_is_valid(entry: &WorkflowCatalogEntry) -> bool {
743 entry.winner
744 && entry.status == WorkflowStatus::Valid
745 && !entry.id.trim().is_empty()
746 && !entry.name.trim().is_empty()
747}
748
749fn invocation_policy(entry: &WorkflowCatalogEntry) -> CapabilityInvocationPolicy {
750 CapabilityInvocationPolicy {
751 explicit: entry.invocation_policy["explicit"].as_bool() == Some(true),
752 automatic: entry.invocation_policy["automatic"].as_bool() == Some(true),
753 }
754}
755
756pub(crate) fn capability_source(source: WorkflowSource) -> CapabilitySource {
757 match source {
758 WorkflowSource::Builtin => CapabilitySource::Builtin,
759 WorkflowSource::Project => CapabilitySource::Project,
760 WorkflowSource::Workspace => CapabilitySource::Workspace,
761 WorkflowSource::User => CapabilitySource::User,
762 WorkflowSource::Plugin => CapabilitySource::Plugin,
763 }
764}
765
766fn source_precedence(source: CapabilitySource) -> u8 {
767 match source {
768 CapabilitySource::Workspace => 8,
769 CapabilitySource::Project => 7,
770 CapabilitySource::User => 6,
771 CapabilitySource::Plugin => 5,
772 CapabilitySource::Builtin => 4,
773 CapabilitySource::Server => 3,
774 CapabilitySource::Mcp => 2,
775 CapabilitySource::Custom => 1,
776 }
777}
778
779fn prefer_tool_projection(
780 candidate: &ToolCapabilityMetadata,
781 existing: &ToolCapabilityMetadata,
782) -> bool {
783 source_precedence(candidate.source)
784 .cmp(&source_precedence(existing.source))
785 .then_with(|| candidate.summary.cmp(&existing.summary))
786 .then_with(|| candidate.canonical_name.cmp(&existing.canonical_name))
787 .is_gt()
788}
789
790fn source_for_tool(name: &str) -> CapabilitySource {
791 if bamboo_domain::BUILTIN_TOOL_NAMES.contains(&name) {
792 CapabilitySource::Builtin
793 } else if bamboo_domain::SERVER_CAPABILITY_NAMES.contains(&name) {
794 CapabilitySource::Server
795 } else if name.starts_with("mcp__") {
796 CapabilitySource::Mcp
797 } else {
798 CapabilitySource::Custom
799 }
800}
801
802fn aliases_for_tool(canonical_name: &str) -> Vec<String> {
803 bamboo_domain::BUILTIN_TOOL_ALIASES
804 .iter()
805 .filter(|(_, canonical)| *canonical == canonical_name)
806 .map(|(alias, _)| (*alias).to_string())
807 .collect()
808}
809
810pub(crate) fn bounded_summary(value: &str) -> String {
811 let compact = value.split_whitespace().collect::<Vec<_>>().join(" ");
812 if compact.chars().count() <= MAX_CAPABILITY_SUMMARY_CHARS {
813 return compact;
814 }
815 let mut bounded = compact
816 .chars()
817 .take(MAX_CAPABILITY_SUMMARY_CHARS.saturating_sub(1))
818 .collect::<String>();
819 bounded.push('…');
820 bounded
821}
822
823fn normalize_search_text(value: &str) -> String {
824 let mut normalized = String::new();
825 let mut previous_was_lower_or_digit = false;
826 let mut pending_space = false;
827
828 for character in value.chars() {
829 if character.is_alphanumeric() {
830 if (pending_space || (character.is_uppercase() && previous_was_lower_or_digit))
831 && !normalized.is_empty()
832 {
833 normalized.push(' ');
834 }
835 for lowercase in character.to_lowercase() {
836 normalized.push(lowercase);
837 }
838 previous_was_lower_or_digit = character.is_lowercase() || character.is_numeric();
839 pending_space = false;
840 } else {
841 pending_space = true;
842 previous_was_lower_or_digit = false;
843 }
844 }
845
846 normalized.trim().to_string()
847}
848
849fn search_tokens(normalized: &str) -> BTreeSet<String> {
850 normalized
851 .split_whitespace()
852 .filter(|token| !token.is_empty())
853 .map(str::to_string)
854 .collect()
855}
856
857#[cfg(test)]
858mod tests {
859 use super::*;
860 use bamboo_domain::FunctionSchema;
861 use serde_json::json;
862
863 use crate::{ShadowedWorkflowCandidate, WorkflowCatalogSnapshot};
864
865 fn schema(name: &str, description: &str, parameters: serde_json::Value) -> ToolSchema {
866 ToolSchema {
867 schema_type: "function".to_string(),
868 function: FunctionSchema {
869 name: name.to_string(),
870 description: description.to_string(),
871 parameters,
872 },
873 }
874 }
875
876 fn catalog_entry(
877 id: &str,
878 name: &str,
879 description: &str,
880 kind: WorkflowKind,
881 source: WorkflowSource,
882 revision: u64,
883 ) -> WorkflowCatalogEntry {
884 WorkflowCatalogEntry {
885 id: id.to_string(),
886 name: name.to_string(),
887 description: description.to_string(),
888 kind,
889 source,
890 revision,
891 content_digest: "digest".to_string(),
892 version: "1".to_string(),
893 invocation_policy: json!({"explicit": true, "automatic": true}),
894 argument_schema: json!({"type": "object"}),
895 status: WorkflowStatus::Valid,
896 legacy: false,
897 migration_status: None,
898 last_error: None,
899 winner: true,
900 shadowed_candidates: Vec::new(),
901 }
902 }
903
904 fn request(query: &str) -> DiscoverCapabilitiesRequest {
905 DiscoverCapabilitiesRequest {
906 query: query.to_string(),
907 kinds: None,
908 limit: None,
909 }
910 }
911
912 fn index(
913 schemas: &[ToolSchema],
914 skills: Vec<WorkflowCatalogEntry>,
915 workflows: Vec<WorkflowCatalogEntry>,
916 eligibility: CapabilityDiscoveryEligibility,
917 ) -> CapabilityDiscoveryIndex {
918 CapabilityDiscoveryIndex::from_snapshots(
919 project_tool_capability_metadata(schemas),
920 &WorkflowCatalogSnapshot {
921 revision: 11,
922 entries: skills,
923 },
924 &WorkflowCatalogSnapshot {
925 revision: 12,
926 entries: workflows,
927 },
928 &eligibility,
929 )
930 }
931
932 #[test]
933 fn one_query_searches_tools_skills_and_workflows_with_typed_targets() {
934 let schemas = [schema(
935 "GitInspect",
936 "Inspect repository status, history, and diffs",
937 json!({"type": "object"}),
938 )];
939 let mut skill = catalog_entry(
940 "review-helper",
941 "Review Helper",
942 "Inspect a change before review",
943 WorkflowKind::Instruction,
944 WorkflowSource::User,
945 7,
946 );
947 skill.invocation_policy = json!({
948 "explicit": true,
949 "automatic": true,
950 "requires_confirmation": true
951 });
952 let workflow = catalog_entry(
953 "review-pipeline",
954 "Review Pipeline",
955 "Inspect and review a repository change",
956 WorkflowKind::Orchestration,
957 WorkflowSource::Workspace,
958 9,
959 );
960 let result = index(
961 &schemas,
962 vec![skill],
963 vec![workflow],
964 CapabilityDiscoveryEligibility::default(),
965 )
966 .discover(&request("inspect repository change"))
967 .expect("bounded discovery");
968
969 assert_eq!(result.query, "inspect repository change");
970 assert_eq!(result.matches.len(), 3);
971 assert_eq!(
972 result
973 .matches
974 .iter()
975 .map(|candidate| candidate.kind)
976 .collect::<BTreeSet<_>>(),
977 BTreeSet::from([
978 CapabilityKind::Tool,
979 CapabilityKind::Skill,
980 CapabilityKind::Workflow,
981 ])
982 );
983 assert!(result.matches.iter().any(|candidate| matches!(
984 &candidate.invocation_target,
985 CapabilityInvocationTarget::Skill {
986 name,
987 skill_id,
988 source: CapabilitySource::User,
989 revision: 7,
990 } if name == "load_skill" && skill_id == "review-helper"
991 )));
992 assert_eq!(
993 result
994 .matches
995 .iter()
996 .find(|candidate| candidate.capability_ref == "skill:review-helper")
997 .and_then(|candidate| candidate.invocation_policy),
998 Some(CapabilityInvocationPolicy {
999 explicit: true,
1000 automatic: true,
1001 })
1002 );
1003 assert!(result.matches.iter().any(|candidate| matches!(
1004 &candidate.invocation_target,
1005 CapabilityInvocationTarget::Workflow {
1006 name,
1007 workflow_id,
1008 source: CapabilitySource::Workspace,
1009 revision: 9,
1010 } if name == "workflow_run" && workflow_id == "review-pipeline"
1011 )));
1012 }
1013
1014 #[test]
1015 fn kind_filter_limit_and_empty_results_are_bounded() {
1016 let skill = catalog_entry(
1017 "inspect-skill",
1018 "Inspect Skill",
1019 "Inspect source",
1020 WorkflowKind::Instruction,
1021 WorkflowSource::Builtin,
1022 1,
1023 );
1024 let index = index(
1025 &[schema("InspectTool", "Inspect source", json!({}))],
1026 vec![skill],
1027 Vec::new(),
1028 CapabilityDiscoveryEligibility::default(),
1029 );
1030 let result = index
1031 .discover(&DiscoverCapabilitiesRequest {
1032 query: "inspect".to_string(),
1033 kinds: Some(vec![CapabilityKind::Skill]),
1034 limit: Some(1),
1035 })
1036 .expect("filtered discovery");
1037 assert_eq!(result.matches.len(), 1);
1038 assert_eq!(result.matches[0].kind, CapabilityKind::Skill);
1039
1040 for query in ["", " ", "no-such-capability"] {
1041 assert!(index
1042 .discover(&request(query))
1043 .expect("typed empty result")
1044 .matches
1045 .is_empty());
1046 }
1047 assert!(index
1048 .discover(&DiscoverCapabilitiesRequest {
1049 query: "inspect".to_string(),
1050 kinds: Some(Vec::new()),
1051 limit: None,
1052 })
1053 .expect("empty kind set")
1054 .matches
1055 .is_empty());
1056 }
1057
1058 #[test]
1059 fn validation_rejects_unbounded_requests() {
1060 let index = CapabilityDiscoveryIndex::default();
1061 assert!(matches!(
1062 index.discover(&request(&"q".repeat(MAX_DISCOVERY_QUERY_CHARS + 1))),
1063 Err(CapabilityDiscoveryError::QueryTooLong { .. })
1064 ));
1065 assert!(matches!(
1066 index.discover(&DiscoverCapabilitiesRequest {
1067 query: "query".to_string(),
1068 kinds: None,
1069 limit: Some(MAX_DISCOVERY_RESULTS + 1),
1070 }),
1071 Err(CapabilityDiscoveryError::LimitTooLarge { .. })
1072 ));
1073 assert!(matches!(
1074 index.discover(&DiscoverCapabilitiesRequest {
1075 query: "query".to_string(),
1076 kinds: Some(vec![
1077 CapabilityKind::Tool,
1078 CapabilityKind::Skill,
1079 CapabilityKind::Workflow,
1080 CapabilityKind::Tool,
1081 ]),
1082 limit: None,
1083 }),
1084 Err(CapabilityDiscoveryError::TooManyKinds { .. })
1085 ));
1086 }
1087
1088 #[test]
1089 fn default_limit_never_returns_more_than_five_matches() {
1090 let schemas = (0..8)
1091 .map(|index| schema(&format!("Inspect{index}"), "inspect source", json!({})))
1092 .collect::<Vec<_>>();
1093 let result = index(
1094 &schemas,
1095 Vec::new(),
1096 Vec::new(),
1097 CapabilityDiscoveryEligibility::default(),
1098 )
1099 .discover(&request("inspect"))
1100 .expect("default bounded result");
1101
1102 assert_eq!(result.matches.len(), MAX_DISCOVERY_RESULTS);
1103 }
1104
1105 #[test]
1106 fn exact_alias_wins_and_serialization_is_deterministic() {
1107 let schemas = [
1108 schema("Edit", "Apply a patch to a file", json!({})),
1109 schema("PatchAdvisor", "Explain apply_patch usage", json!({})),
1110 ];
1111 let index = index(
1112 &schemas,
1113 Vec::new(),
1114 Vec::new(),
1115 CapabilityDiscoveryEligibility::default(),
1116 );
1117 let first = index.discover(&request("apply_patch")).expect("alias");
1118 let second = index.discover(&request("apply_patch")).expect("alias");
1119
1120 assert_eq!(first.matches[0].capability_ref, "tool:Edit");
1121 assert_eq!(
1122 serde_json::to_string(&first).expect("serialize"),
1123 serde_json::to_string(&second).expect("serialize")
1124 );
1125 }
1126
1127 #[test]
1128 fn exact_registered_identity_ranks_ahead_of_alias_and_case_fallbacks() {
1129 let index = index(
1130 &[
1131 schema("Edit", "builtin edit", json!({})),
1132 schema("apply_patch", "custom exact patch", json!({})),
1133 schema("Foo", "upper custom", json!({})),
1134 schema("foo", "lower custom", json!({})),
1135 ],
1136 Vec::new(),
1137 Vec::new(),
1138 CapabilityDiscoveryEligibility::default(),
1139 );
1140
1141 let patch = index
1142 .discover(&request("apply_patch"))
1143 .expect("exact custom alias query");
1144 assert_eq!(patch.matches[0].capability_ref, "tool:apply_patch");
1145 let legacy_patch = index
1146 .discover(&request("applyPatch"))
1147 .expect("legacy spelling resolves through exact custom alias");
1148 assert_eq!(legacy_patch.matches[0].capability_ref, "tool:apply_patch");
1149
1150 let lower = index.discover(&request("foo")).expect("exact case query");
1151 assert_eq!(lower.matches[0].capability_ref, "tool:foo");
1152 let upper = index.discover(&request("Foo")).expect("exact case query");
1153 assert_eq!(upper.matches[0].capability_ref, "tool:Foo");
1154 }
1155
1156 #[test]
1157 fn serialization_is_byte_identical_across_snapshot_input_order() {
1158 let tools = vec![
1159 ToolCapabilityMetadata {
1160 canonical_name: "InspectBeta".to_string(),
1161 summary: "inspect source".to_string(),
1162 source: CapabilitySource::Custom,
1163 aliases: Vec::new(),
1164 available: true,
1165 },
1166 ToolCapabilityMetadata {
1167 canonical_name: "InspectAlpha".to_string(),
1168 summary: "inspect source".to_string(),
1169 source: CapabilitySource::Custom,
1170 aliases: Vec::new(),
1171 available: true,
1172 },
1173 ];
1174 let skills = vec![
1175 catalog_entry(
1176 "inspect-beta",
1177 "Inspect Beta",
1178 "inspect source",
1179 WorkflowKind::Instruction,
1180 WorkflowSource::User,
1181 2,
1182 ),
1183 catalog_entry(
1184 "inspect-alpha",
1185 "Inspect Alpha",
1186 "inspect source",
1187 WorkflowKind::Instruction,
1188 WorkflowSource::User,
1189 1,
1190 ),
1191 ];
1192 let build = |tools: Vec<ToolCapabilityMetadata>, skills: Vec<WorkflowCatalogEntry>| {
1193 CapabilityDiscoveryIndex::from_snapshots(
1194 tools,
1195 &WorkflowCatalogSnapshot {
1196 revision: 1,
1197 entries: skills,
1198 },
1199 &WorkflowCatalogSnapshot::default(),
1200 &CapabilityDiscoveryEligibility::default(),
1201 )
1202 .discover(&request("inspect source"))
1203 .expect("deterministic discovery")
1204 };
1205
1206 let mut reversed_tools = tools.clone();
1207 reversed_tools.reverse();
1208 let mut reversed_skills = skills.clone();
1209 reversed_skills.reverse();
1210 let first = build(tools, skills);
1211 let second = build(reversed_tools, reversed_skills);
1212
1213 assert_eq!(
1214 serde_json::to_vec(&first).expect("serialize first"),
1215 serde_json::to_vec(&second).expect("serialize second")
1216 );
1217 }
1218
1219 #[test]
1220 fn ranking_uses_exact_then_name_summary_source_and_stable_ref() {
1221 let mut builtin = catalog_entry(
1222 "review-b",
1223 "Review",
1224 "Review a change",
1225 WorkflowKind::Instruction,
1226 WorkflowSource::Builtin,
1227 1,
1228 );
1229 let workspace = catalog_entry(
1230 "review-z",
1231 "Review",
1232 "Review a change",
1233 WorkflowKind::Instruction,
1234 WorkflowSource::Workspace,
1235 2,
1236 );
1237 let workspace_a = catalog_entry(
1238 "review-a",
1239 "Review",
1240 "Review a change",
1241 WorkflowKind::Instruction,
1242 WorkflowSource::Workspace,
1243 3,
1244 );
1245 builtin.description = "Review a change with details".to_string();
1246 let index = index(
1247 &[],
1248 vec![builtin, workspace, workspace_a],
1249 Vec::new(),
1250 CapabilityDiscoveryEligibility::default(),
1251 );
1252 let ranked = index.discover(&request("review")).expect("ranked");
1253 assert_eq!(ranked.matches[0].capability_ref, "skill:review-a");
1254 assert_eq!(ranked.matches[1].capability_ref, "skill:review-z");
1255
1256 let exact = index.discover(&request("review-b")).expect("exact");
1257 assert_eq!(exact.matches[0].capability_ref, "skill:review-b");
1258 }
1259
1260 #[test]
1261 fn automatic_skill_accepts_unique_exact_normalized_identities() {
1262 let skill = catalog_entry(
1263 "review-helper",
1264 "Review Helper",
1265 "Review a code change carefully",
1266 WorkflowKind::Instruction,
1267 WorkflowSource::User,
1268 7,
1269 );
1270 let index = index(
1271 &[],
1272 vec![skill],
1273 Vec::new(),
1274 CapabilityDiscoveryEligibility {
1275 skill_invocation: InvocationEligibility::Automatic,
1276 ..Default::default()
1277 },
1278 );
1279
1280 for query in ["review-helper", "skill:review-helper", "Review_Helper"] {
1281 let matched = index
1282 .discover_unambiguous_automatic_skill(query)
1283 .unwrap_or_else(|| panic!("unique exact match for {query}"));
1284 assert_eq!(matched.capability_ref, "skill:review-helper");
1285 assert!(matches!(
1286 matched.invocation_target,
1287 CapabilityInvocationTarget::Skill {
1288 skill_id,
1289 source: CapabilitySource::User,
1290 revision: 7,
1291 ..
1292 } if skill_id == "review-helper"
1293 ));
1294 }
1295 }
1296
1297 #[test]
1298 fn automatic_skill_accepts_only_unique_strong_fuzzy_match() {
1299 let react = catalog_entry(
1300 "react-optimizer",
1301 "React Optimizer",
1302 "Improve React and Vite build performance",
1303 WorkflowKind::Instruction,
1304 WorkflowSource::Project,
1305 11,
1306 );
1307 let index = index(
1308 &[],
1309 vec![react],
1310 Vec::new(),
1311 CapabilityDiscoveryEligibility {
1312 skill_invocation: InvocationEligibility::Automatic,
1313 ..Default::default()
1314 },
1315 );
1316
1317 let matched = index
1318 .discover_unambiguous_automatic_skill("please improve the react vite build")
1319 .expect("multiple distinct summary tokens are a strong fuzzy match");
1320 assert_eq!(matched.capability_ref, "skill:react-optimizer");
1321 }
1322
1323 #[test]
1324 fn automatic_skill_ignores_a_higher_ranked_weak_candidate() {
1325 let weak = catalog_entry(
1326 "only-alpha",
1327 "only-alpha",
1328 "only-alpha project skill",
1329 WorkflowKind::Instruction,
1330 WorkflowSource::Workspace,
1331 1,
1332 );
1333 let strong = catalog_entry(
1334 "shared-workflow",
1335 "shared-workflow",
1336 "alpha needle workflow",
1337 WorkflowKind::Instruction,
1338 WorkflowSource::Workspace,
1339 2,
1340 );
1341 let index = index(
1342 &[],
1343 vec![weak, strong],
1344 Vec::new(),
1345 CapabilityDiscoveryEligibility {
1346 skill_invocation: InvocationEligibility::Automatic,
1347 ..Default::default()
1348 },
1349 );
1350
1351 assert_eq!(
1352 index
1353 .discover_unambiguous_automatic_skill("alpha needle")
1354 .expect("the unique strong summary match")
1355 .capability_ref,
1356 "skill:shared-workflow"
1357 );
1358 }
1359
1360 #[test]
1361 fn automatic_skill_abstains_for_weak_tied_and_missing_matches() {
1362 let weak = catalog_entry(
1363 "artifact-helper",
1364 "Artifact Helper",
1365 "Handles PDF artifacts",
1366 WorkflowKind::Instruction,
1367 WorkflowSource::User,
1368 1,
1369 );
1370 let first = catalog_entry(
1371 "review-first",
1372 "Review Assistant",
1373 "Review code changes carefully",
1374 WorkflowKind::Instruction,
1375 WorkflowSource::User,
1376 2,
1377 );
1378 let mut second = catalog_entry(
1379 "review-second",
1380 "Review Assistant",
1381 "Review code changes carefully",
1382 WorkflowKind::Instruction,
1383 WorkflowSource::Workspace,
1384 3,
1385 );
1386 second.invocation_policy = json!({"explicit": false, "automatic": true});
1387 let policy_only_index = index(
1388 &[],
1389 vec![weak.clone()],
1390 Vec::new(),
1391 CapabilityDiscoveryEligibility {
1392 skill_invocation: InvocationEligibility::Automatic,
1393 ..Default::default()
1394 },
1395 );
1396 assert!(policy_only_index
1397 .discover_unambiguous_automatic_skill("please use explicit automatic mode")
1398 .is_none());
1399 let index = index(
1400 &[],
1401 vec![weak, first, second],
1402 Vec::new(),
1403 CapabilityDiscoveryEligibility {
1404 skill_invocation: InvocationEligibility::Automatic,
1405 ..Default::default()
1406 },
1407 );
1408
1409 assert!(index.discover_unambiguous_automatic_skill("pdf").is_none());
1410 assert!(index
1411 .discover_unambiguous_automatic_skill("please review code changes")
1412 .is_none());
1413 assert!(index
1414 .discover_unambiguous_automatic_skill("explicit review code")
1415 .is_none());
1416 assert!(index
1417 .discover_unambiguous_automatic_skill("unrelated request")
1418 .is_none());
1419 assert!(index.discover_unambiguous_automatic_skill("").is_none());
1420 }
1421
1422 #[test]
1423 fn automatic_skill_abstains_when_an_exact_display_name_is_not_unique() {
1424 let first = catalog_entry(
1425 "review-first",
1426 "Shared Review",
1427 "Review code",
1428 WorkflowKind::Instruction,
1429 WorkflowSource::User,
1430 1,
1431 );
1432 let second = catalog_entry(
1433 "review-second",
1434 "Shared Review",
1435 "Review another change",
1436 WorkflowKind::Instruction,
1437 WorkflowSource::Workspace,
1438 2,
1439 );
1440 let index = index(
1441 &[],
1442 vec![first, second],
1443 Vec::new(),
1444 CapabilityDiscoveryEligibility {
1445 skill_invocation: InvocationEligibility::Automatic,
1446 ..Default::default()
1447 },
1448 );
1449
1450 assert!(index
1451 .discover_unambiguous_automatic_skill("Shared Review")
1452 .is_none());
1453 assert_eq!(
1454 index
1455 .discover_unambiguous_automatic_skill("review-first")
1456 .expect("canonical id remains unique")
1457 .capability_ref,
1458 "skill:review-first"
1459 );
1460 }
1461
1462 #[test]
1463 fn automatic_skill_bounds_long_user_hints_without_failing() {
1464 let skill = catalog_entry(
1465 "review",
1466 "Review",
1467 "Review code changes",
1468 WorkflowKind::Instruction,
1469 WorkflowSource::User,
1470 1,
1471 );
1472 let index = index(
1473 &[],
1474 vec![skill],
1475 Vec::new(),
1476 CapabilityDiscoveryEligibility {
1477 skill_invocation: InvocationEligibility::Automatic,
1478 ..Default::default()
1479 },
1480 );
1481 let hint = format!("review {}", "界".repeat(MAX_DISCOVERY_QUERY_CHARS + 20));
1482
1483 assert_eq!(
1484 index
1485 .discover_unambiguous_automatic_skill(&hint)
1486 .expect("bounded prefix keeps the match")
1487 .capability_ref,
1488 "skill:review"
1489 );
1490 assert!(matches!(
1491 index.discover(&request(&hint)),
1492 Err(CapabilityDiscoveryError::QueryTooLong { .. })
1493 ));
1494 }
1495
1496 #[test]
1497 fn disabled_surface_ineligible_invalid_shadowed_and_unavailable_entries_are_filtered() {
1498 let mut invalid = catalog_entry(
1499 "invalid-skill",
1500 "Inspect Invalid",
1501 "inspect",
1502 WorkflowKind::Instruction,
1503 WorkflowSource::User,
1504 1,
1505 );
1506 invalid.status = WorkflowStatus::Invalid;
1507 invalid.last_error = Some("private invalid diagnostic".to_string());
1508 let mut shadowed = catalog_entry(
1509 "shadowed-skill",
1510 "Inspect Shadowed",
1511 "inspect",
1512 WorkflowKind::Instruction,
1513 WorkflowSource::User,
1514 2,
1515 );
1516 shadowed.winner = false;
1517 let disabled = catalog_entry(
1518 "disabled-skill",
1519 "Inspect Disabled",
1520 "inspect",
1521 WorkflowKind::Instruction,
1522 WorkflowSource::User,
1523 3,
1524 );
1525 let mut denied = catalog_entry(
1526 "denied-skill",
1527 "Inspect Denied",
1528 "inspect",
1529 WorkflowKind::Instruction,
1530 WorkflowSource::User,
1531 4,
1532 );
1533 denied.invocation_policy = json!({"explicit": false, "automatic": false});
1534 let allowed = catalog_entry(
1535 "allowed-skill",
1536 "Inspect Allowed",
1537 "inspect",
1538 WorkflowKind::Instruction,
1539 WorkflowSource::User,
1540 5,
1541 );
1542 let eligibility = CapabilityDiscoveryEligibility {
1543 disabled_tool_names: BTreeSet::from(["HiddenTool".to_string()]),
1544 allowed_tool_names: Some(BTreeSet::from(["VisibleTool".to_string()])),
1545 disabled_skill_ids: BTreeSet::from(["disabled-skill".to_string()]),
1546 allowed_skill_ids: Some(BTreeSet::from(["allowed-skill".to_string()])),
1547 workflow_gateway_available: false,
1548 ..Default::default()
1549 };
1550 let workflow = catalog_entry(
1551 "hidden-workflow",
1552 "Inspect Workflow",
1553 "inspect",
1554 WorkflowKind::Orchestration,
1555 WorkflowSource::User,
1556 6,
1557 );
1558 let index = index(
1559 &[
1560 schema("HiddenTool", "inspect", json!({})),
1561 schema("SurfaceHiddenTool", "inspect", json!({})),
1562 schema("VisibleTool", "inspect", json!({})),
1563 ],
1564 vec![invalid, shadowed, disabled, denied, allowed],
1565 vec![workflow],
1566 eligibility,
1567 );
1568 let result = index.discover(&request("inspect")).expect("filtered");
1569 assert_eq!(
1570 result
1571 .matches
1572 .iter()
1573 .map(|candidate| candidate.capability_ref.as_str())
1574 .collect::<BTreeSet<_>>(),
1575 BTreeSet::from(["skill:allowed-skill", "tool:VisibleTool"])
1576 );
1577 }
1578
1579 #[test]
1580 fn each_catalog_eligibility_predicate_fails_closed_independently() {
1581 let is_visible = |entry: WorkflowCatalogEntry,
1582 eligibility: CapabilityDiscoveryEligibility| {
1583 let query = entry.id.clone();
1584 !index(&[], vec![entry], Vec::new(), eligibility)
1585 .discover(&request(&query))
1586 .expect("eligibility query")
1587 .matches
1588 .is_empty()
1589 };
1590
1591 let eligible = catalog_entry(
1592 "eligible-skill",
1593 "Eligible Skill",
1594 "inspect",
1595 WorkflowKind::Instruction,
1596 WorkflowSource::User,
1597 1,
1598 );
1599 assert!(is_visible(
1600 eligible.clone(),
1601 CapabilityDiscoveryEligibility::default()
1602 ));
1603
1604 let mut invalid = eligible.clone();
1605 invalid.status = WorkflowStatus::Invalid;
1606 assert!(!is_visible(
1607 invalid,
1608 CapabilityDiscoveryEligibility::default()
1609 ));
1610
1611 let mut shadowed = eligible.clone();
1612 shadowed.winner = false;
1613 assert!(!is_visible(
1614 shadowed,
1615 CapabilityDiscoveryEligibility::default()
1616 ));
1617
1618 assert!(!is_visible(
1619 eligible.clone(),
1620 CapabilityDiscoveryEligibility {
1621 disabled_skill_ids: BTreeSet::from([eligible.id.clone()]),
1622 ..Default::default()
1623 }
1624 ));
1625
1626 let mut denied = eligible.clone();
1627 denied.invocation_policy = json!({"explicit": false, "automatic": false});
1628 assert!(!is_visible(
1629 denied,
1630 CapabilityDiscoveryEligibility::default()
1631 ));
1632
1633 let mut explicit_only = eligible.clone();
1634 explicit_only.invocation_policy = json!({"explicit": true, "automatic": false});
1635 assert!(is_visible(
1636 explicit_only.clone(),
1637 CapabilityDiscoveryEligibility {
1638 skill_invocation: InvocationEligibility::Explicit,
1639 ..Default::default()
1640 }
1641 ));
1642 assert!(!is_visible(
1643 explicit_only,
1644 CapabilityDiscoveryEligibility {
1645 skill_invocation: InvocationEligibility::Automatic,
1646 ..Default::default()
1647 }
1648 ));
1649
1650 assert!(!is_visible(
1651 eligible,
1652 CapabilityDiscoveryEligibility {
1653 skill_gateway_available: false,
1654 ..Default::default()
1655 }
1656 ));
1657 }
1658
1659 #[test]
1660 fn tool_disable_and_surface_allowlist_are_independent() {
1661 let tools = [
1662 ToolCapabilityMetadata {
1663 canonical_name: "DisabledTool".to_string(),
1664 summary: "inspect".to_string(),
1665 source: CapabilitySource::Custom,
1666 aliases: Vec::new(),
1667 available: true,
1668 },
1669 ToolCapabilityMetadata {
1670 canonical_name: "AllowedTool".to_string(),
1671 summary: "inspect".to_string(),
1672 source: CapabilitySource::Custom,
1673 aliases: Vec::new(),
1674 available: true,
1675 },
1676 ToolCapabilityMetadata {
1677 canonical_name: "SurfaceHiddenTool".to_string(),
1678 summary: "inspect".to_string(),
1679 source: CapabilitySource::Custom,
1680 aliases: Vec::new(),
1681 available: true,
1682 },
1683 ];
1684 let disabled_result = CapabilityDiscoveryIndex::from_snapshots(
1685 tools.clone(),
1686 &WorkflowCatalogSnapshot::default(),
1687 &WorkflowCatalogSnapshot::default(),
1688 &CapabilityDiscoveryEligibility {
1689 disabled_tool_names: BTreeSet::from(["DisabledTool".to_string()]),
1690 ..Default::default()
1691 },
1692 )
1693 .discover(&request("inspect"))
1694 .expect("disabled tool eligibility");
1695 assert_eq!(
1696 disabled_result
1697 .matches
1698 .iter()
1699 .map(|candidate| candidate.capability_ref.as_str())
1700 .collect::<BTreeSet<_>>(),
1701 BTreeSet::from(["tool:AllowedTool", "tool:SurfaceHiddenTool"])
1702 );
1703
1704 let surface_result = CapabilityDiscoveryIndex::from_snapshots(
1705 tools,
1706 &WorkflowCatalogSnapshot::default(),
1707 &WorkflowCatalogSnapshot::default(),
1708 &CapabilityDiscoveryEligibility {
1709 allowed_tool_names: Some(BTreeSet::from(["AllowedTool".to_string()])),
1710 ..Default::default()
1711 },
1712 )
1713 .discover(&request("inspect"))
1714 .expect("tool surface eligibility");
1715
1716 assert_eq!(surface_result.matches.len(), 1);
1717 assert_eq!(surface_result.matches[0].capability_ref, "tool:AllowedTool");
1718 }
1719
1720 #[test]
1721 fn unavailable_tool_metadata_and_skill_gateway_fail_closed() {
1722 let skill = catalog_entry(
1723 "hidden-skill",
1724 "Inspect Skill",
1725 "inspect",
1726 WorkflowKind::Instruction,
1727 WorkflowSource::User,
1728 1,
1729 );
1730 let eligibility = CapabilityDiscoveryEligibility {
1731 skill_gateway_available: false,
1732 ..Default::default()
1733 };
1734 let index = CapabilityDiscoveryIndex::from_snapshots(
1735 [
1736 ToolCapabilityMetadata {
1737 canonical_name: "UnavailableTool".to_string(),
1738 summary: "inspect".to_string(),
1739 source: CapabilitySource::Custom,
1740 aliases: Vec::new(),
1741 available: false,
1742 },
1743 ToolCapabilityMetadata {
1744 canonical_name: "AvailableTool".to_string(),
1745 summary: "inspect".to_string(),
1746 source: CapabilitySource::Custom,
1747 aliases: Vec::new(),
1748 available: true,
1749 },
1750 ],
1751 &WorkflowCatalogSnapshot {
1752 revision: 1,
1753 entries: vec![skill],
1754 },
1755 &WorkflowCatalogSnapshot::default(),
1756 &eligibility,
1757 );
1758 let result = index.discover(&request("inspect")).expect("fail closed");
1759
1760 assert_eq!(result.matches.len(), 1);
1761 assert_eq!(result.matches[0].capability_ref, "tool:AvailableTool");
1762 }
1763
1764 #[test]
1765 fn projection_excludes_schemas_paths_diagnostics_and_shadowed_metadata() {
1766 const SECRET: &str = "PRIVATE-DISCOVERY-SENTINEL";
1767 const PATH: &str = "/private/hidden/workflow.yaml";
1768 let schemas = [schema(
1769 "SafeTool",
1770 "Inspect safely",
1771 json!({"secret": SECRET, "path": PATH}),
1772 )];
1773 let mut skill = catalog_entry(
1774 "safe-skill",
1775 "Safe Skill",
1776 "Inspect safely",
1777 WorkflowKind::Instruction,
1778 WorkflowSource::Project,
1779 42,
1780 );
1781 skill.argument_schema = json!({"secret": SECRET, "path": PATH});
1782 skill.content_digest = SECRET.to_string();
1783 skill.last_error = Some(format!("{SECRET} at {PATH}"));
1784 skill.invocation_policy = json!({
1785 "explicit": true,
1786 "automatic": false,
1787 "credential": SECRET,
1788 "workspace_path": PATH,
1789 "input_schema": {"description": SECRET},
1790 "padding": "x".repeat(16_384)
1791 });
1792 skill.shadowed_candidates = vec![ShadowedWorkflowCandidate {
1793 source: WorkflowSource::Plugin,
1794 status: WorkflowStatus::Invalid,
1795 legacy: false,
1796 migration_status: None,
1797 last_error: Some(format!("{SECRET} at {PATH}")),
1798 }];
1799 let result = index(
1800 &schemas,
1801 vec![skill],
1802 Vec::new(),
1803 CapabilityDiscoveryEligibility::default(),
1804 )
1805 .discover(&request("inspect safely"))
1806 .expect("safe projection");
1807 let serialized = serde_json::to_string(&result).expect("serialize result");
1808
1809 assert!(!serialized.contains(SECRET));
1810 assert!(!serialized.contains(PATH));
1811 assert!(serialized.contains("\"revision\":42"));
1812 assert!(serialized.contains("\"source\":\"project\""));
1813 assert!(serialized.contains("\"invocation_policy\""));
1814 assert!(!serialized.contains("credential"));
1815 assert!(!serialized.contains("workspace_path"));
1816 assert!(!serialized.contains("input_schema"));
1817 assert!(serialized.len() < 2_048);
1818 }
1819
1820 #[test]
1821 fn tool_projection_keeps_exact_alias_shadow_separate_and_bounds_summaries() {
1822 let long = "x".repeat(MAX_CAPABILITY_SUMMARY_CHARS + 40);
1823 let metadata = project_tool_capability_metadata(&[
1824 schema("Edit", &long, json!({})),
1825 schema("apply_patch", "short alias", json!({})),
1826 ]);
1827 assert_eq!(metadata.len(), 2);
1828 let edit = metadata
1829 .iter()
1830 .find(|entry| entry.canonical_name == "Edit")
1831 .expect("builtin Edit projection");
1832 assert_eq!(edit.source, CapabilitySource::Builtin);
1833 assert_eq!(edit.aliases, vec!["apply_patch"]);
1834 let custom_alias = metadata
1835 .iter()
1836 .find(|entry| entry.canonical_name == "apply_patch")
1837 .expect("exact custom alias projection");
1838 assert_eq!(custom_alias.source, CapabilitySource::Custom);
1839 assert!(custom_alias.aliases.is_empty());
1840 let result = CapabilityDiscoveryIndex::from_snapshots(
1841 metadata,
1842 &WorkflowCatalogSnapshot::default(),
1843 &WorkflowCatalogSnapshot::default(),
1844 &CapabilityDiscoveryEligibility::default(),
1845 )
1846 .discover(&request("edit"))
1847 .expect("deduplicated");
1848
1849 assert_eq!(result.matches.len(), 1);
1850 assert_eq!(result.matches[0].capability_ref, "tool:Edit");
1851 assert!(result.matches[0].summary.chars().count() <= MAX_CAPABILITY_SUMMARY_CHARS);
1852 }
1853
1854 #[test]
1855 fn framework_source_and_alias_metadata_require_exact_registered_identity() {
1856 let metadata = project_tool_capability_metadata(&[
1857 schema("exec_command", "canonical builtin", json!({})),
1858 schema("Exec_Command", "custom mixed case", json!({})),
1859 schema("Project", "canonical server", json!({})),
1860 schema("browser", "server browser", json!({})),
1861 schema("project", "custom lowercase", json!({})),
1862 schema("Edit", "canonical edit", json!({})),
1863 schema("edit", "custom lowercase", json!({})),
1864 schema("mcp__alpha__inspect", "canonical mcp alias", json!({})),
1865 schema("MCP__alpha__inspect", "custom uppercase prefix", json!({})),
1866 ]);
1867 let entry = |name: &str| {
1868 metadata
1869 .iter()
1870 .find(|entry| entry.canonical_name == name)
1871 .unwrap_or_else(|| panic!("missing {name}"))
1872 };
1873
1874 assert_eq!(entry("exec_command").source, CapabilitySource::Builtin);
1875 assert_eq!(entry("Exec_Command").source, CapabilitySource::Custom);
1876 assert_eq!(entry("Project").source, CapabilitySource::Server);
1877 assert_eq!(entry("browser").source, CapabilitySource::Server);
1878 assert_eq!(entry("project").source, CapabilitySource::Custom);
1879 assert_eq!(entry("Edit").aliases, vec!["apply_patch"]);
1880 assert!(entry("edit").aliases.is_empty());
1881 assert_eq!(entry("mcp__alpha__inspect").source, CapabilitySource::Mcp);
1882 assert_eq!(
1883 entry("MCP__alpha__inspect").source,
1884 CapabilitySource::Custom
1885 );
1886 }
1887
1888 #[test]
1889 fn raw_and_classified_projections_exclude_host_only_and_keep_deferred_tools() {
1890 let schemas = [
1891 schema("exec_command", "inspect core", json!({})),
1892 schema("Glob", "inspect deferred", json!({})),
1893 schema("custom_tool", "inspect custom", json!({})),
1894 schema("mcp__alpha__inspect", "inspect mcp", json!({})),
1895 schema("Workspace", "inspect host", json!({})),
1896 schema("request_permissions", "inspect host", json!({})),
1897 ];
1898 let raw = project_tool_capability_metadata(&schemas);
1899 let classified = schemas
1900 .iter()
1901 .cloned()
1902 .filter_map(ClassifiedToolSchema::new)
1903 .collect::<Vec<_>>();
1904 let typed = project_classified_tool_capability_metadata(&classified);
1905 let names = |items: &[ToolCapabilityMetadata]| {
1906 items
1907 .iter()
1908 .map(|item| item.canonical_name.clone())
1909 .collect::<BTreeSet<_>>()
1910 };
1911
1912 assert_eq!(names(&raw), names(&typed));
1913 assert_eq!(
1914 names(&raw),
1915 BTreeSet::from([
1916 "exec_command".to_string(),
1917 "Glob".to_string(),
1918 "custom_tool".to_string(),
1919 "mcp__alpha__inspect".to_string(),
1920 ])
1921 );
1922 }
1923
1924 #[test]
1925 fn snapshot_boundary_rejects_forged_host_only_metadata() {
1926 let mut metadata = [
1927 "Workspace",
1928 "request_permissions",
1929 "GetCurrentDir",
1930 "SetWorkspace",
1931 ]
1932 .into_iter()
1933 .map(|name| ToolCapabilityMetadata {
1934 canonical_name: name.to_string(),
1935 summary: "inspect host".to_string(),
1936 source: CapabilitySource::Custom,
1937 aliases: Vec::new(),
1938 available: true,
1939 })
1940 .collect::<Vec<_>>();
1941 metadata.push(ToolCapabilityMetadata {
1942 canonical_name: "VisibleTool".to_string(),
1943 summary: "inspect visible".to_string(),
1944 source: CapabilitySource::Custom,
1945 aliases: Vec::new(),
1946 available: true,
1947 });
1948 let result = CapabilityDiscoveryIndex::from_snapshots(
1949 metadata,
1950 &WorkflowCatalogSnapshot::default(),
1951 &WorkflowCatalogSnapshot::default(),
1952 &CapabilityDiscoveryEligibility::default(),
1953 )
1954 .discover(&request("inspect"))
1955 .expect("host-only boundary");
1956
1957 assert_eq!(result.matches.len(), 1);
1958 assert_eq!(result.matches[0].capability_ref, "tool:VisibleTool");
1959 }
1960
1961 #[test]
1962 fn disabled_aliases_use_the_same_canonical_identity_as_discovery() {
1963 let eligibility = CapabilityDiscoveryEligibility {
1964 disabled_tool_names: BTreeSet::from([
1965 "apply_patch".to_string(),
1966 "FileExists".to_string(),
1967 "sub_session_manager".to_string(),
1968 ]),
1969 ..Default::default()
1970 };
1971 let result = index(
1972 &[
1973 schema("Edit", "inspect edit", json!({})),
1974 schema("GetFileInfo", "inspect file", json!({})),
1975 schema("SubAgent", "inspect agent", json!({})),
1976 schema("VisibleTool", "inspect visible", json!({})),
1977 ],
1978 Vec::new(),
1979 Vec::new(),
1980 eligibility,
1981 )
1982 .discover(&request("inspect"))
1983 .expect("canonical disabled filter");
1984
1985 assert_eq!(result.matches.len(), 1);
1986 assert_eq!(result.matches[0].capability_ref, "tool:VisibleTool");
1987 }
1988
1989 #[test]
1990 fn eligibility_resolves_exact_shadow_before_alias_fallback() {
1991 let schemas = [
1992 schema("Edit", "builtin edit", json!({})),
1993 schema("apply_patch", "custom exact patch", json!({})),
1994 ];
1995 let allowed_builtin = index(
1996 &schemas,
1997 Vec::new(),
1998 Vec::new(),
1999 CapabilityDiscoveryEligibility {
2000 allowed_tool_names: Some(BTreeSet::from(["Edit".to_string()])),
2001 ..Default::default()
2002 },
2003 )
2004 .discover(&request("patch"))
2005 .expect("exact allowed builtin");
2006 assert_eq!(allowed_builtin.matches.len(), 1);
2007 assert_eq!(allowed_builtin.matches[0].capability_ref, "tool:Edit");
2008
2009 let allowed_shadow = index(
2010 &schemas,
2011 Vec::new(),
2012 Vec::new(),
2013 CapabilityDiscoveryEligibility {
2014 allowed_tool_names: Some(BTreeSet::from(["apply_patch".to_string()])),
2015 ..Default::default()
2016 },
2017 )
2018 .discover(&request("patch"))
2019 .expect("exact allowed custom shadow");
2020 assert_eq!(allowed_shadow.matches.len(), 1);
2021 assert_eq!(allowed_shadow.matches[0].capability_ref, "tool:apply_patch");
2022
2023 let alias_fallback = index(
2024 &[schema("Edit", "builtin edit", json!({}))],
2025 Vec::new(),
2026 Vec::new(),
2027 CapabilityDiscoveryEligibility {
2028 allowed_tool_names: Some(BTreeSet::from(["apply_patch".to_string()])),
2029 ..Default::default()
2030 },
2031 )
2032 .discover(&request("patch"))
2033 .expect("unshadowed alias fallback");
2034 assert_eq!(alias_fallback.matches.len(), 1);
2035 assert_eq!(alias_fallback.matches[0].capability_ref, "tool:Edit");
2036
2037 let disabled_shadow = index(
2038 &schemas,
2039 Vec::new(),
2040 Vec::new(),
2041 CapabilityDiscoveryEligibility {
2042 disabled_tool_names: BTreeSet::from(["apply_patch".to_string()]),
2043 ..Default::default()
2044 },
2045 )
2046 .discover(&request("edit"))
2047 .expect("exact disabled custom shadow");
2048 assert_eq!(disabled_shadow.matches.len(), 1);
2049 assert_eq!(disabled_shadow.matches[0].capability_ref, "tool:Edit");
2050 }
2051
2052 #[test]
2053 fn case_distinct_dynamic_and_mcp_identities_do_not_collapse() {
2054 let metadata = project_tool_capability_metadata(&[
2055 schema("Foo", "inspect custom", json!({})),
2056 schema("foo", "inspect custom", json!({})),
2057 schema("mcp__Alpha__inspect", "inspect mcp", json!({})),
2058 schema("mcp__alpha__inspect", "inspect mcp", json!({})),
2059 ]);
2060 assert_eq!(metadata.len(), 4);
2061
2062 let eligibility = CapabilityDiscoveryEligibility {
2063 disabled_tool_names: BTreeSet::from(["mcp__Alpha__inspect".to_string()]),
2064 ..Default::default()
2065 };
2066 let result = CapabilityDiscoveryIndex::from_snapshots(
2067 metadata,
2068 &WorkflowCatalogSnapshot::default(),
2069 &WorkflowCatalogSnapshot::default(),
2070 &eligibility,
2071 )
2072 .discover(&request("inspect"))
2073 .expect("case-preserving discovery");
2074 let refs = result
2075 .matches
2076 .iter()
2077 .map(|entry| entry.capability_ref.as_str())
2078 .collect::<BTreeSet<_>>();
2079
2080 assert_eq!(refs.len(), 3);
2081 assert!(refs.contains("tool:Foo"));
2082 assert!(refs.contains("tool:foo"));
2083 assert!(refs.contains("tool:mcp__alpha__inspect"));
2084 assert!(!refs.contains("tool:mcp__Alpha__inspect"));
2085
2086 let allowed = CapabilityDiscoveryIndex::from_snapshots(
2087 project_tool_capability_metadata(&[
2088 schema("Foo", "inspect custom", json!({})),
2089 schema("foo", "inspect custom", json!({})),
2090 schema("mcp__Alpha__inspect", "inspect mcp", json!({})),
2091 schema("mcp__alpha__inspect", "inspect mcp", json!({})),
2092 ]),
2093 &WorkflowCatalogSnapshot::default(),
2094 &WorkflowCatalogSnapshot::default(),
2095 &CapabilityDiscoveryEligibility {
2096 allowed_tool_names: Some(BTreeSet::from([
2097 "Foo".to_string(),
2098 "mcp__alpha__inspect".to_string(),
2099 ])),
2100 ..Default::default()
2101 },
2102 )
2103 .discover(&request("inspect"))
2104 .expect("case-preserving allowlist");
2105 let allowed_refs = allowed
2106 .matches
2107 .iter()
2108 .map(|entry| entry.capability_ref.as_str())
2109 .collect::<BTreeSet<_>>();
2110 assert_eq!(
2111 allowed_refs,
2112 BTreeSet::from(["tool:Foo", "tool:mcp__alpha__inspect"])
2113 );
2114 }
2115
2116 #[test]
2117 fn ordinary_discover_named_function_is_searchable_and_disableable() {
2118 let schemas = [schema(
2119 "discover",
2120 "discover a custom data source",
2121 json!({}),
2122 )];
2123 let visible = index(
2124 &schemas,
2125 Vec::new(),
2126 Vec::new(),
2127 CapabilityDiscoveryEligibility::default(),
2128 )
2129 .discover(&request("discover custom"))
2130 .expect("ordinary discover-named function");
2131 assert_eq!(visible.matches.len(), 1);
2132 assert_eq!(visible.matches[0].capability_ref, "tool:discover");
2133
2134 let hidden = index(
2135 &schemas,
2136 Vec::new(),
2137 Vec::new(),
2138 CapabilityDiscoveryEligibility {
2139 disabled_tool_names: BTreeSet::from(["discover".to_string()]),
2140 ..Default::default()
2141 },
2142 )
2143 .discover(&request("discover custom"))
2144 .expect("disabled ordinary function");
2145 assert!(hidden.matches.is_empty());
2146 }
2147
2148 #[tokio::test]
2149 async fn store_facade_builds_from_atomic_metadata_snapshots() {
2150 let store = crate::SkillStore::default();
2151 let catalog = [schema(
2152 "SafeTool",
2153 "Inspect safely",
2154 json!({"private_schema": "must-not-be-read"}),
2155 )]
2156 .into_iter()
2157 .filter_map(ClassifiedToolSchema::new)
2158 .collect::<Vec<_>>();
2159 let index = CapabilityDiscoveryIndex::from_resolved_classified_store(
2160 &catalog,
2161 &store,
2162 &CapabilityDiscoveryEligibility::default(),
2163 )
2164 .await;
2165
2166 let result = index
2167 .discover(&request("inspect safely"))
2168 .expect("metadata-only store projection");
2169 assert_eq!(result.matches.len(), 1);
2170 assert_eq!(result.matches[0].capability_ref, "tool:SafeTool");
2171
2172 let edit_schema = schema("Edit", "Edit safely", json!({}));
2173 let resolved_catalog = [edit_schema.clone()]
2174 .into_iter()
2175 .filter_map(ClassifiedToolSchema::new)
2176 .collect::<Vec<_>>();
2177 let disabled_alias = CapabilityDiscoveryEligibility {
2178 disabled_tool_names: BTreeSet::from(["apply_patch".to_string()]),
2179 ..Default::default()
2180 };
2181 let resolved = CapabilityDiscoveryIndex::from_resolved_classified_store(
2182 &resolved_catalog,
2183 &store,
2184 &disabled_alias,
2185 )
2186 .await
2187 .discover(&request("edit"))
2188 .expect("resolved catalogs must not be filtered twice");
2189 assert_eq!(resolved.matches[0].capability_ref, "tool:Edit");
2190
2191 let legacy = CapabilityDiscoveryIndex::from_store(&[edit_schema], &store, &disabled_alias)
2192 .await
2193 .discover(&request("edit"))
2194 .expect("raw schemas still resolve tool eligibility");
2195 assert!(legacy.matches.is_empty());
2196 }
2197 #[test]
2198 fn automatic_decisions_explain_exact_weak_ambiguous_and_missing_matches() {
2199 let entries = vec![
2200 catalog_entry(
2201 "review-a",
2202 "Review Alpha",
2203 "review code changes",
2204 WorkflowKind::Instruction,
2205 WorkflowSource::User,
2206 7,
2207 ),
2208 catalog_entry(
2209 "review-b",
2210 "Review Beta",
2211 "review code changes",
2212 WorkflowKind::Instruction,
2213 WorkflowSource::User,
2214 8,
2215 ),
2216 ];
2217 let search = index(
2218 &[],
2219 entries,
2220 vec![],
2221 CapabilityDiscoveryEligibility {
2222 skill_invocation: InvocationEligibility::Automatic,
2223 ..Default::default()
2224 },
2225 );
2226 for (query, expected) in [
2227 ("", AutomaticSkillOutcome::EmptyQuery),
2228 ("review-a", AutomaticSkillOutcome::ExactMatch),
2229 ("review", AutomaticSkillOutcome::BelowThreshold),
2230 (
2231 "please review code changes",
2232 AutomaticSkillOutcome::Ambiguous,
2233 ),
2234 ("request-text-marker", AutomaticSkillOutcome::NoMatch),
2235 ] {
2236 let decision = search.explain_automatic_skill(query);
2237 assert_eq!(decision.outcome, expected, "{query}");
2238 assert!(decision.candidates.len() <= 3);
2239 assert_eq!(
2240 decision.selected,
2241 search.discover_unambiguous_automatic_skill(query)
2242 );
2243 assert_eq!(decision, search.explain_automatic_skill(query));
2244 }
2245 let exact = search.explain_automatic_skill("review-a");
2246 assert_eq!(exact.selected.unwrap().revision, Some(7));
2247 let encoded =
2248 serde_json::to_string(&search.explain_automatic_skill("request-text-marker")).unwrap();
2249 assert!(!encoded.contains("request-text-marker"));
2250 }
2251
2252 #[test]
2253 fn decision_candidates_respect_disabled_and_shadowed_catalog_entries() {
2254 let mut shadow = catalog_entry(
2255 "shadow",
2256 "Review",
2257 "review code",
2258 WorkflowKind::Instruction,
2259 WorkflowSource::Builtin,
2260 1,
2261 );
2262 shadow.winner = false;
2263 let disabled = catalog_entry(
2264 "disabled",
2265 "Review",
2266 "review code",
2267 WorkflowKind::Instruction,
2268 WorkflowSource::User,
2269 2,
2270 );
2271 let search = index(
2272 &[],
2273 vec![shadow, disabled],
2274 vec![],
2275 CapabilityDiscoveryEligibility {
2276 disabled_skill_ids: BTreeSet::from(["disabled".into()]),
2277 ..Default::default()
2278 },
2279 );
2280 let decision = search.explain_automatic_skill("review code");
2281 assert_eq!(decision.outcome, AutomaticSkillOutcome::NoMatch);
2282 assert!(decision.candidates.is_empty());
2283 assert!(decision.selected.is_none());
2284 }
2285}