ryu_tool_registry/lib.rs
1//! Unified tool-catalog primitive (#474, P1) — extracted from `apps/core`.
2//!
3//! One searchable catalog across **MCP servers + built-ins + Composio + plugin
4//! tools + Agent Skills** — no parallel registry. [`run_search`] ranks descriptors
5//! with a
6//! **swappable [`ToolRanker`]** (BM25 default, semantic rerank as a second impl
7//! seam, selectable via a pref key mirroring `catalog.active_source.{kind}`).
8//! [`describe_from_parts`] / [`describe_composio`] return a tool's argument
9//! schema.
10//!
11//! Contract 1 (spec Appendix A, verbatim): [`ToolKind`] / [`ToolDescriptor`] /
12//! [`DescribedTool`] / [`DescribedArg`].
13//!
14//! ## The boundary type is [`ToolDescriptor`], never Core's `RegistryTool`
15//!
16//! This crate owns the catalog *contract + ranker + describe-shaping* — the
17//! portable data layer. What stays Core-side (bound to the `McpRegistry`
18//! sidecar object + the built-in server inventory) is the ingest adapter:
19//! Core's `descriptor_from(&RegistryTool)` maps its registry rows into
20//! [`ToolDescriptor`], `classify_kind` resolves the [`ToolKind`] from the
21//! sidecar server inventory, and the Composio live fetch produces the composio
22//! descriptors. Core then hands those descriptors to [`run_search`] /
23//! [`describe_from_parts`]. So the crate never sees a Core type — zero
24//! dependency on `apps/core`.
25//!
26//! ## The embedder seam ([`ToolEmbedder`])
27//!
28//! [`ToolRanker::Semantic`] embeds the query + candidates and ranks by cosine
29//! similarity. The embedder is injected as a narrow [`ToolEmbedder`] trait
30//! object; Core wraps its registry-driven `retrieval::Embedder` behind this in
31//! `apps/core/src/tool_registry_host.rs` (the `SearchEmbedder`/`search_host.rs`
32//! precedent).
33//!
34//! Placement (CLAUDE.md §1): discovering *what tools exist* and ranking them is
35//! orchestration → Core. The allowlist verdict / budget / audit is Gateway.
36
37use async_trait::async_trait;
38use serde::{Deserialize, Serialize};
39use serde_json::Value;
40
41/// A minimal embedder seam for [`ToolRanker::Semantic`]. Core implements this in
42/// `tool_registry_host.rs` over its registry-configured `retrieval::Embedder`
43/// so this crate never depends on `apps/core`. `embed` returns `None` when the
44/// embedder is unreachable, which the ranker treats as a documented BM25
45/// fallback (not an error).
46#[async_trait]
47pub trait ToolEmbedder: Send + Sync {
48 /// Embed one text into a vector, or `None` when the embedder is unreachable.
49 async fn embed(&self, text: &str) -> Option<Vec<f32>>;
50}
51
52/// Source plane of a catalog entry. Serializes lowercase: `mcp|builtin|composio|app`,
53/// plus `core-api` for Core's own HTTP endpoints exposed as agent-drivable tools,
54/// `command` for a declarative app tool that execs an allowlisted local CLI, and
55/// `skill` for an Agent Skill.
56///
57/// ## `Skill` is the one kind that is not callable
58///
59/// Every other variant names a *function the model may invoke*. [`ToolKind::Skill`]
60/// names **instruction text the model may load** — an Agent Skill discovered through
61/// the same catalog so a model faces one search door instead of two, but reached with
62/// `skills__load` rather than by calling its id. The kind is the model's (and the
63/// gateway's) signal for that distinction: Core's `McpRegistry::describe` points a
64/// skill row at `skills__load`, the gateway declines to inject skill rows as function
65/// definitions, and Core's `skills` provider refuses a call that names a skill id as
66/// a tool. Discovery is unified; execution is not.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "lowercase")]
69pub enum ToolKind {
70 Mcp,
71 Builtin,
72 Composio,
73 App,
74 /// A Core HTTP endpoint (OpenAPI-derived) callable by an agent over loopback.
75 /// Explicit rename so the wire value is the hyphenated `core-api`, not the
76 /// `rename_all = "lowercase"` default `coreapi`.
77 #[serde(rename = "core-api")]
78 CoreApi,
79 /// A declarative `command` app tool: execs an allowlisted local CLI through
80 /// the governed tool-exec path. Surfaced as its own kind so `?kind=command`
81 /// selects these; the other app backends (http/inline_deno/alias) stay `App`.
82 Command,
83 /// An Agent Skill: instruction text loaded with `skills__load`, **not** a
84 /// callable function. Ids are namespaced `skills__<slug>` so a skill row lives
85 /// in the same id space as the `skills__*` tools that serve it — which is what
86 /// makes the allowlist arm below, and Core's refusal path, work without a
87 /// bespoke lookup.
88 Skill,
89}
90
91impl ToolKind {
92 /// Every variant, in wire order — the single list both gateway mirrors and
93 /// Core's ACP bridge enumerate when asserting that their advertised
94 /// `tool_search.kind` enum covers every plane Core can filter on.
95 ///
96 /// **Adding a variant means adding it here.** [`ToolKind::wire_name`] below is a
97 /// wildcard-free `match`, so a new variant is a compile error there first; this
98 /// constant is two lines above it precisely so the same edit updates both. A
99 /// variant present in the enum but missing from `ALL` would make those mirror
100 /// tests pass vacuously — the exact failure mode that let `core-api` and
101 /// `command` stay invisible to every model for two releases.
102 pub const ALL: &'static [ToolKind] = &[
103 ToolKind::Mcp,
104 ToolKind::Builtin,
105 ToolKind::Composio,
106 ToolKind::App,
107 ToolKind::CoreApi,
108 ToolKind::Command,
109 ToolKind::Skill,
110 ];
111
112 /// The canonical wire spelling — the value [`ToolKind::parse_filter`] round-trips
113 /// and the one a `?kind=` / `tool_search.kind` filter must use.
114 ///
115 /// Exhaustive with no wildcard arm on purpose: that is the drift alarm. See
116 /// [`ToolKind::ALL`].
117 pub const fn wire_name(self) -> &'static str {
118 match self {
119 ToolKind::Mcp => "mcp",
120 ToolKind::Builtin => "builtin",
121 ToolKind::Composio => "composio",
122 ToolKind::App => "app",
123 ToolKind::CoreApi => "core-api",
124 ToolKind::Command => "command",
125 ToolKind::Skill => "skill",
126 }
127 }
128
129 /// Parse the `?kind=` / `tool_search.kind` value. `any` → `None` (no filter);
130 /// an unknown value also yields `None` so callers can treat it as "any".
131 pub fn parse_filter(s: &str) -> Option<ToolKind> {
132 match s.trim().to_ascii_lowercase().as_str() {
133 "mcp" => Some(ToolKind::Mcp),
134 "builtin" => Some(ToolKind::Builtin),
135 "composio" => Some(ToolKind::Composio),
136 "app" => Some(ToolKind::App),
137 // Accept both the canonical hyphenated form and the underscore/no-sep
138 // variants callers may send.
139 "core-api" | "core_api" | "coreapi" => Some(ToolKind::CoreApi),
140 "command" => Some(ToolKind::Command),
141 "skill" | "skills" => Some(ToolKind::Skill),
142 _ => None, // "any" or unknown
143 }
144 }
145}
146
147/// A ranked tool descriptor (Contract 1).
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct ToolDescriptor {
150 /// `<server>__<tool>` | `composio__<slug>`.
151 pub id: String,
152 pub name: String,
153 /// Never null — `""` when absent.
154 #[serde(default)]
155 pub description: String,
156 pub kind: ToolKind,
157 #[serde(default)]
158 pub arg_names: Vec<String>,
159 #[serde(default)]
160 pub arg_descriptions: Vec<String>,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub score: Option<f32>,
163 /// The tool's `_meta`, verbatim (widget keys), when present.
164 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub meta: Option<Value>,
166 /// Whether a widget originating from this tool may `callTool` (companion).
167 #[serde(default)]
168 pub widget_accessible: bool,
169 /// The `ui://widget/<slug>.html` template uri when this tool renders a widget.
170 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub output_template: Option<String>,
172}
173
174impl ToolDescriptor {
175 /// Whether this descriptor is reachable under an agent's tool `allowlist`,
176 /// matching the *execution* gate ([`super::tool_allowed`]) so a `?agent=`
177 /// search view does not under-report tools the agent can actually call:
178 /// for MCP/built-in/app tools an entry may be the fully-qualified id, the
179 /// bare tool name, **or** the server segment; for Composio it is matched on
180 /// the fully-qualified id only (Composio ids have no name/server grant form,
181 /// and id-only is the cross-plane-bypass guard on the call path).
182 ///
183 /// ## [`ToolKind::Skill`]: id or server segment, never the bare name
184 ///
185 /// A skill row's id is `skills__<slug>`, so its server segment is the `skills`
186 /// provider — the grant that lets an agent call `skills__load` at all. Matching
187 /// on id-or-server therefore mirrors the execution gate's *tool-allowlist half*
188 /// exactly: an agent with no grant on the `skills` server cannot load any skill,
189 /// so surfacing skill rows to it would advertise nothing reachable.
190 ///
191 /// The bare `name` is deliberately excluded. A skill's `name` is human prose
192 /// ("Resolve merge conflicts"), and the default arm's `e == name` would let an
193 /// allowlist entry written for a tool (`search`, meant for `exa__search`) match
194 /// a skill that happens to be *called* "search" — the same cross-plane
195 /// bare-name match the gateway's `is_allowed` doc records as security fix #1.
196 ///
197 /// **What this does NOT check** is the agent's per-agent *skill* allowlist
198 /// (`AgentRecord.skills`), which is a different list this crate never sees; it
199 /// is what `skills__search` / `skills__load` scope on. So under a tool
200 /// allowlist that grants `skills`, this returns `true` for every enabled skill,
201 /// including ones outside that agent's skill allowlist — which `skills__load`
202 /// will still refuse. See `McpRegistry::search_scoped` for where the skill
203 /// allowlist *is* applied and which plane still misses it.
204 pub fn matches_allowlist(&self, allowlist: &[String]) -> bool {
205 if self.kind == ToolKind::Composio {
206 return allowlist.iter().any(|e| e == &self.id);
207 }
208 let (server, name) = self
209 .id
210 .split_once("__")
211 .map_or((self.id.as_str(), self.name.as_str()), |(s, t)| (s, t));
212 if self.kind == ToolKind::Skill {
213 return allowlist.iter().any(|e| e == &self.id || e == server);
214 }
215 allowlist
216 .iter()
217 .any(|e| e == &self.id || e == name || e == server)
218 }
219}
220
221/// A fully-described tool with its argument schema (Contract 1).
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct DescribedTool {
224 pub id: String,
225 pub name: String,
226 #[serde(default)]
227 pub description: String,
228 pub kind: ToolKind,
229 pub args: Vec<DescribedArg>,
230 /// True when the schema could not be fully resolved (e.g. a Composio action
231 /// whose only known argument is the freeform `arguments` object).
232 #[serde(default)]
233 pub shallow: bool,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub parameters: Option<Value>,
236}
237
238/// One argument of a [`DescribedTool`] (Contract 1).
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct DescribedArg {
241 pub name: String,
242 pub r#type: String,
243 #[serde(default)]
244 pub description: String,
245 pub required: bool,
246}
247
248/// Extract `(arg_names, arg_descriptions)` from a JSON-schema `input_schema`.
249/// The `RegistryTool`→[`ToolDescriptor`] ingest adapter lives Core-side; this is
250/// exported so that adapter can reuse the same arg-name extraction.
251pub fn arg_summary(schema: Option<&Value>) -> (Vec<String>, Vec<String>) {
252 let mut names = Vec::new();
253 let mut descs = Vec::new();
254 if let Some(props) = schema
255 .and_then(|s| s.get("properties"))
256 .and_then(Value::as_object)
257 {
258 for (name, def) in props {
259 names.push(name.clone());
260 descs.push(
261 def.get("description")
262 .and_then(Value::as_str)
263 .unwrap_or_default()
264 .to_string(),
265 );
266 }
267 }
268 (names, descs)
269}
270
271/// Extract the full `DescribedArg` list from an `input_schema`.
272pub fn described_args(schema: Option<&Value>) -> Vec<DescribedArg> {
273 let Some(schema) = schema else {
274 return Vec::new();
275 };
276 let required: Vec<String> = schema
277 .get("required")
278 .and_then(Value::as_array)
279 .map(|a| {
280 a.iter()
281 .filter_map(Value::as_str)
282 .map(str::to_string)
283 .collect()
284 })
285 .unwrap_or_default();
286 let Some(props) = schema.get("properties").and_then(Value::as_object) else {
287 return Vec::new();
288 };
289 props
290 .iter()
291 .map(|(name, def)| DescribedArg {
292 name: name.clone(),
293 r#type: def
294 .get("type")
295 .and_then(Value::as_str)
296 .unwrap_or("string")
297 .to_string(),
298 description: def
299 .get("description")
300 .and_then(Value::as_str)
301 .unwrap_or_default()
302 .to_string(),
303 required: required.iter().any(|r| r == name),
304 })
305 .collect()
306}
307
308// ── Ranker (swappable; nothing hardcoded) ────────────────────────────────────
309
310/// Pref key selecting the active ranker, mirroring `catalog.active_source.{kind}`.
311pub const RANKER_PREF_KEY: &str = "tools.active_ranker";
312
313/// A swappable tool ranking strategy. BM25 is the default; `Semantic` is a real
314/// embedder-backed second strategy (enum-dispatch in [`ToolRanker::rank`]), not a
315/// placeholder — it embeds the query + candidates and ranks by cosine similarity.
316#[derive(Debug, Clone, Copy, PartialEq, Eq)]
317pub enum ToolRanker {
318 /// Classic BM25 lexical ranking over name + description + arg names.
319 Bm25,
320 /// Embedding-based semantic ranking via the registry [`Embedder`]
321 /// (cosine over `doc_text`). Falls back to BM25 ordering when the embedder is
322 /// unreachable (documented graceful fallback, not a stub error).
323 Semantic,
324}
325
326impl ToolRanker {
327 /// Resolve the ranker from a pref string; defaults to BM25.
328 pub fn from_pref(s: Option<&str>) -> ToolRanker {
329 match s.map(|v| v.trim().to_ascii_lowercase()).as_deref() {
330 Some("semantic") => ToolRanker::Semantic,
331 _ => ToolRanker::Bm25,
332 }
333 }
334
335 /// Rank descriptors against a query, mutating `score` and sorting descending.
336 /// Returns the top `limit`.
337 ///
338 /// `Semantic` embeds the query + each candidate's [`doc_text`] via the
339 /// injected [`ToolEmbedder`] and ranks by cosine similarity; it falls back to
340 /// BM25 ordering when the embedder is absent/unreachable (or the query is
341 /// empty), so it degrades gracefully rather than erroring. `Bm25` is the pure
342 /// lexical path and ignores `embedder`.
343 pub async fn rank(
344 self,
345 query: &str,
346 mut items: Vec<ToolDescriptor>,
347 limit: usize,
348 embedder: Option<&dyn ToolEmbedder>,
349 ) -> Vec<ToolDescriptor> {
350 let scored = match (self, embedder) {
351 (ToolRanker::Semantic, Some(embedder)) => {
352 semantic_score(query, &mut items, embedder).await
353 }
354 _ => false,
355 };
356 if !scored {
357 // BM25 path (also the Semantic fallback when no embedder is reachable).
358 bm25_score(query, &mut items);
359 }
360 items.sort_by(|a, b| {
361 b.score
362 .unwrap_or(0.0)
363 .partial_cmp(&a.score.unwrap_or(0.0))
364 .unwrap_or(std::cmp::Ordering::Equal)
365 });
366 items.truncate(limit);
367 items
368 }
369}
370
371/// Cosine similarity of two equal-length vectors; `0.0` on length mismatch.
372fn cosine(a: &[f32], b: &[f32]) -> f32 {
373 if a.len() != b.len() {
374 return 0.0;
375 }
376 let mut dot = 0.0_f32;
377 let mut na = 0.0_f32;
378 let mut nb = 0.0_f32;
379 for (x, y) in a.iter().zip(b.iter()) {
380 dot += x * y;
381 na += x * x;
382 nb += y * y;
383 }
384 let denom = na.sqrt() * nb.sqrt();
385 if denom > f32::EPSILON {
386 dot / denom
387 } else {
388 0.0
389 }
390}
391
392/// Score `items` in place by embedding cosine similarity. Returns `true` when the
393/// semantic path ran (every item scored), `false` to signal the caller to fall
394/// back to BM25 (empty query, or the query embedding failed → embedder
395/// unreachable). A single per-item embedding failure scores that item `0.0`.
396async fn semantic_score(
397 query: &str,
398 items: &mut [ToolDescriptor],
399 embedder: &dyn ToolEmbedder,
400) -> bool {
401 if query.trim().is_empty() || items.is_empty() {
402 return false;
403 }
404 let Some(q_vec) = embedder.embed(query).await else {
405 // Embedder unreachable → documented BM25 fallback.
406 return false;
407 };
408 for d in items.iter_mut() {
409 let score = match embedder.embed(&doc_text(d)).await {
410 Some(doc_vec) => cosine(&q_vec, &doc_vec),
411 None => 0.0,
412 };
413 d.score = Some(score);
414 }
415 true
416}
417
418/// Tokenize on non-alphanumeric boundaries, lowercased.
419fn tokenize(s: &str) -> Vec<String> {
420 s.split(|c: char| !c.is_alphanumeric())
421 .filter(|t| !t.is_empty())
422 .map(|t| t.to_ascii_lowercase())
423 .collect()
424}
425
426/// The searchable text of a descriptor (id + name + description + arg names).
427fn doc_text(d: &ToolDescriptor) -> String {
428 let mut s = format!("{} {} {}", d.id, d.name, d.description);
429 for a in &d.arg_names {
430 s.push(' ');
431 s.push_str(a);
432 }
433 s
434}
435
436/// Score `items` in place with BM25; an exact id/name match gets a strong boost
437/// so it ranks first (acceptance: BM25 ranks exact match first).
438fn bm25_score(query: &str, items: &mut [ToolDescriptor]) {
439 const K1: f32 = 1.5;
440 const B: f32 = 0.75;
441 let q_terms = tokenize(query);
442 if q_terms.is_empty() {
443 for d in items.iter_mut() {
444 d.score = Some(0.0);
445 }
446 return;
447 }
448
449 let docs: Vec<Vec<String>> = items.iter().map(|d| tokenize(&doc_text(d))).collect();
450 let n = docs.len().max(1) as f32;
451 let avg_dl = docs.iter().map(|d| d.len() as f32).sum::<f32>() / n;
452 let avg_dl = if avg_dl == 0.0 { 1.0 } else { avg_dl };
453
454 let q_lower = query.trim().to_ascii_lowercase();
455
456 for (i, d) in items.iter_mut().enumerate() {
457 let doc = &docs[i];
458 let dl = doc.len() as f32;
459 let mut score = 0.0_f32;
460 for term in &q_terms {
461 let tf = doc.iter().filter(|w| *w == term).count() as f32;
462 if tf == 0.0 {
463 continue;
464 }
465 // Document frequency across the candidate set.
466 let df = docs.iter().filter(|dd| dd.contains(term)).count() as f32;
467 let idf = (((n - df + 0.5) / (df + 0.5)) + 1.0).ln();
468 let denom = tf + K1 * (1.0 - B + B * dl / avg_dl);
469 score += idf * (tf * (K1 + 1.0)) / denom;
470 }
471 // Exact id / name match boost so it sorts first.
472 if d.id.eq_ignore_ascii_case(&q_lower) || d.name.eq_ignore_ascii_case(&q_lower) {
473 score += 1000.0;
474 }
475 d.score = Some(score);
476 }
477}
478
479/// Run the unified tool-catalog search over already-gathered descriptors — the
480/// pure body of Core's `McpRegistry::search`.
481///
482/// `builtin_candidates` are the `list_all_tools()` rows Core mapped via its
483/// `descriptor_from` ingest adapter; they are filtered by `kind` (`None` = any).
484/// `composio_candidates` are the live, key-gated Composio descriptors Core
485/// already fetched (empty when Composio is not wanted/configured); they are
486/// **searchable-not-listed** and bypass the `kind` filter (Core only fetches
487/// them when `kind` includes Composio), matching the pre-extraction ordering.
488/// The merged set is ranked by `ranker` (BM25 default; Semantic uses `embedder`).
489pub async fn run_search(
490 query: &str,
491 builtin_candidates: Vec<ToolDescriptor>,
492 composio_candidates: Vec<ToolDescriptor>,
493 kind: Option<ToolKind>,
494 limit: usize,
495 ranker: ToolRanker,
496 embedder: Option<&dyn ToolEmbedder>,
497) -> Vec<ToolDescriptor> {
498 let mut candidates: Vec<ToolDescriptor> = builtin_candidates
499 .into_iter()
500 .filter(|d| kind.is_none() || kind == Some(d.kind))
501 .collect();
502 candidates.extend(composio_candidates);
503 ranker.rank(query, candidates, limit, embedder).await
504}
505
506/// Describe a `composio__<slug>` id shallowly: a single freeform `arguments`
507/// object row (the action's full schema is not listed). The pure body of the
508/// Composio branch of Core's `McpRegistry::describe`.
509pub fn describe_composio(id: &str) -> DescribedTool {
510 let slug = id.strip_prefix("composio__").unwrap_or(id);
511 DescribedTool {
512 id: id.to_string(),
513 name: slug.to_string(),
514 description: String::new(),
515 kind: ToolKind::Composio,
516 args: vec![DescribedArg {
517 name: "arguments".to_string(),
518 r#type: "object".to_string(),
519 description: "Action-specific parameters for this Composio action.".to_string(),
520 required: false,
521 }],
522 shallow: true,
523 parameters: None,
524 }
525}
526
527/// Build a fully-described tool from its parts — the pure body of the non-Composio
528/// branch of Core's `McpRegistry::describe`. Core resolves `kind` via its
529/// inventory-bound `classify_kind` and passes the located tool's fields; the
530/// crate owns the arg-schema parsing and the `shallow`/`parameters` shaping.
531pub fn describe_from_parts(
532 id: &str,
533 name: &str,
534 description: &str,
535 kind: ToolKind,
536 input_schema: Option<&Value>,
537) -> DescribedTool {
538 DescribedTool {
539 id: id.to_string(),
540 name: name.to_string(),
541 description: description.to_string(),
542 kind,
543 args: described_args(input_schema),
544 shallow: input_schema.is_none(),
545 parameters: input_schema.cloned(),
546 }
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552
553 fn desc(id: &str, name: &str, description: &str, kind: ToolKind) -> ToolDescriptor {
554 ToolDescriptor {
555 id: id.to_string(),
556 name: name.to_string(),
557 description: description.to_string(),
558 kind,
559 arg_names: Vec::new(),
560 arg_descriptions: Vec::new(),
561 score: None,
562 meta: None,
563 widget_accessible: false,
564 output_template: None,
565 }
566 }
567
568 #[test]
569 fn kind_serializes_lowercase() {
570 assert_eq!(serde_json::to_string(&ToolKind::Mcp).unwrap(), "\"mcp\"");
571 assert_eq!(
572 serde_json::to_string(&ToolKind::Builtin).unwrap(),
573 "\"builtin\""
574 );
575 assert_eq!(
576 serde_json::to_string(&ToolKind::Composio).unwrap(),
577 "\"composio\""
578 );
579 assert_eq!(serde_json::to_string(&ToolKind::App).unwrap(), "\"app\"");
580 // CoreApi carries an explicit hyphenated wire value, not `coreapi`.
581 assert_eq!(
582 serde_json::to_string(&ToolKind::CoreApi).unwrap(),
583 "\"core-api\""
584 );
585 // Command serializes to the lowercase `command` and round-trips.
586 assert_eq!(
587 serde_json::to_string(&ToolKind::Command).unwrap(),
588 "\"command\""
589 );
590 assert_eq!(
591 serde_json::from_str::<ToolKind>("\"command\"").unwrap(),
592 ToolKind::Command
593 );
594 // Skill is the seventh plane; singular on the wire.
595 assert_eq!(
596 serde_json::to_string(&ToolKind::Skill).unwrap(),
597 "\"skill\""
598 );
599 assert_eq!(
600 serde_json::from_str::<ToolKind>("\"skill\"").unwrap(),
601 ToolKind::Skill
602 );
603 }
604
605 /// [`ToolKind::ALL`] is what both gateway mirrors and the ACP bridge enumerate,
606 /// so a variant missing from it makes those parity tests pass vacuously. Assert
607 /// the two properties that would let that happen: every entry round-trips
608 /// through its own wire spelling, and no entry is listed twice.
609 #[test]
610 fn all_round_trips_through_wire_name_without_duplicates() {
611 for k in ToolKind::ALL {
612 assert_eq!(
613 ToolKind::parse_filter(k.wire_name()),
614 Some(*k),
615 "{k:?}'s wire_name is not what parse_filter maps back to it"
616 );
617 // The wire spelling and the serde spelling must be the same string, or
618 // a caller round-tripping a *serialized* descriptor's kind back into
619 // `?kind=` would silently get "no filter".
620 assert_eq!(
621 serde_json::to_string(k).unwrap(),
622 format!("\"{}\"", k.wire_name()),
623 "{k:?} serializes differently from its filter spelling"
624 );
625 }
626 let mut seen: Vec<&str> = ToolKind::ALL.iter().map(|k| k.wire_name()).collect();
627 let before = seen.len();
628 seen.sort_unstable();
629 seen.dedup();
630 assert_eq!(before, seen.len(), "ToolKind::ALL lists a kind twice");
631 }
632
633 #[test]
634 fn parse_filter_maps_any_to_none() {
635 assert_eq!(ToolKind::parse_filter("any"), None);
636 assert_eq!(ToolKind::parse_filter("nonsense"), None);
637 assert_eq!(ToolKind::parse_filter("mcp"), Some(ToolKind::Mcp));
638 assert_eq!(ToolKind::parse_filter("COMPOSIO"), Some(ToolKind::Composio));
639 // Every accepted spelling of the core-api filter round-trips to CoreApi.
640 assert_eq!(ToolKind::parse_filter("core-api"), Some(ToolKind::CoreApi));
641 assert_eq!(ToolKind::parse_filter("core_api"), Some(ToolKind::CoreApi));
642 assert_eq!(ToolKind::parse_filter("CoreApi"), Some(ToolKind::CoreApi));
643 assert_eq!(ToolKind::parse_filter("command"), Some(ToolKind::Command));
644 assert_eq!(ToolKind::parse_filter("COMMAND"), Some(ToolKind::Command));
645 // `skill` is canonical; `skills` (the provider's name) is accepted as an
646 // alias because that is what a model that just read `skills__search` will
647 // reach for.
648 assert_eq!(ToolKind::parse_filter("skill"), Some(ToolKind::Skill));
649 assert_eq!(ToolKind::parse_filter("Skills"), Some(ToolKind::Skill));
650 }
651
652 /// A skill row is reachable via its id or the `skills` server segment (the grant
653 /// that lets an agent call `skills__load` at all), and **never** via its bare
654 /// human-readable name — which would let a tool-shaped allowlist entry match a
655 /// skill across planes.
656 #[test]
657 fn skill_rows_match_on_id_or_server_but_never_on_name() {
658 let s = desc(
659 "skills__merge-conflicts",
660 "search",
661 "resolve conflicts",
662 ToolKind::Skill,
663 );
664 assert!(s.matches_allowlist(&["skills__merge-conflicts".to_string()]));
665 assert!(s.matches_allowlist(&["skills".to_string()]));
666 // The name is "search" — an allowlist entry meant for `exa__search` must
667 // not reach this skill.
668 assert!(!s.matches_allowlist(&["search".to_string()]));
669 assert!(!s.matches_allowlist(&["merge-conflicts".to_string()]));
670 assert!(!s.matches_allowlist(&[]));
671 // The non-skill arm is unchanged: a bare name still matches a real tool.
672 let t = desc("exa__search", "search", "web search", ToolKind::Mcp);
673 assert!(t.matches_allowlist(&["search".to_string()]));
674 }
675
676 /// `kind=skill` selects only skill rows out of a mixed candidate set — the
677 /// property that makes `skills__search` a filtered view of the one catalog
678 /// rather than a second registry.
679 #[tokio::test]
680 async fn run_search_kind_skill_selects_only_skill_rows() {
681 let candidates = vec![
682 desc("exa__search", "search", "search the web", ToolKind::Mcp),
683 desc(
684 "skills__web-research",
685 "Web research",
686 "search the web methodically",
687 ToolKind::Skill,
688 ),
689 ];
690 let out = run_search(
691 "search",
692 candidates,
693 Vec::new(),
694 Some(ToolKind::Skill),
695 25,
696 ToolRanker::Bm25,
697 None,
698 )
699 .await;
700 assert_eq!(out.len(), 1);
701 assert_eq!(out[0].id, "skills__web-research");
702 }
703
704 #[test]
705 fn matches_allowlist_matches_id_name_or_server() {
706 let d = desc("spider__crawl", "crawl", "crawl a site", ToolKind::Mcp);
707 assert!(d.matches_allowlist(&["spider__crawl".to_string()])); // id
708 assert!(d.matches_allowlist(&["crawl".to_string()])); // bare name
709 assert!(d.matches_allowlist(&["spider".to_string()])); // server segment
710 assert!(!d.matches_allowlist(&["other".to_string()]));
711 // Composio is id-only (no name/server grant form).
712 let c = desc("composio__slack", "Slack", "", ToolKind::Composio);
713 assert!(c.matches_allowlist(&["composio__slack".to_string()]));
714 assert!(!c.matches_allowlist(&["Slack".to_string()]));
715 }
716
717 #[tokio::test]
718 async fn bm25_ranks_exact_match_first() {
719 let items = vec![
720 desc("foo__search", "search", "search the web", ToolKind::Mcp),
721 desc(
722 "foo__send",
723 "send_message",
724 "send a search-related message",
725 ToolKind::Mcp,
726 ),
727 desc("foo__noise", "noise", "totally unrelated", ToolKind::Mcp),
728 ];
729 let ranked = ToolRanker::Bm25.rank("search", items, 8, None).await;
730 assert_eq!(ranked[0].name, "search", "exact name match ranks first");
731 assert!(ranked.iter().all(|d| d.score.is_some()));
732 // The unrelated tool should rank last (zero score).
733 assert_eq!(ranked.last().unwrap().name, "noise");
734 }
735
736 #[tokio::test]
737 async fn ranker_selectable_from_pref() {
738 assert_eq!(ToolRanker::from_pref(None), ToolRanker::Bm25);
739 assert_eq!(ToolRanker::from_pref(Some("bm25")), ToolRanker::Bm25);
740 assert_eq!(
741 ToolRanker::from_pref(Some("semantic")),
742 ToolRanker::Semantic
743 );
744 // BM25 path produces a deterministic exact-match-first ordering. (The
745 // Semantic path needs a reachable embedder, which is not asserted here.)
746 let items = vec![
747 desc("foo__search", "search", "find things", ToolKind::Mcp),
748 desc("foo__x", "x", "nothing", ToolKind::Mcp),
749 ];
750 let ranked = ToolRanker::Bm25.rank("search", items, 8, None).await;
751 assert_eq!(ranked[0].name, "search");
752 }
753
754 #[test]
755 fn described_args_extracts_required_flag() {
756 let schema = serde_json::json!({
757 "type": "object",
758 "properties": {
759 "url": { "type": "string", "description": "page url" },
760 "depth": { "type": "integer" }
761 },
762 "required": ["url"]
763 });
764 let mut args = described_args(Some(&schema));
765 args.sort_by(|a, b| a.name.cmp(&b.name));
766 assert_eq!(args.len(), 2);
767 let url = args.iter().find(|a| a.name == "url").unwrap();
768 assert_eq!(url.r#type, "string");
769 assert_eq!(url.description, "page url");
770 assert!(url.required);
771 let depth = args.iter().find(|a| a.name == "depth").unwrap();
772 assert_eq!(depth.r#type, "integer");
773 assert!(!depth.required);
774 }
775
776 #[test]
777 fn describe_composio_id_is_shallow() {
778 let d = describe_composio("composio__GITHUB_CREATE_ISSUE");
779 assert!(d.shallow);
780 assert_eq!(d.kind, ToolKind::Composio);
781 assert_eq!(d.name, "GITHUB_CREATE_ISSUE");
782 assert_eq!(d.args.len(), 1);
783 assert_eq!(d.args[0].name, "arguments");
784 assert_eq!(d.args[0].r#type, "object");
785 }
786
787 #[test]
788 fn describe_from_parts_shapes_schema_and_shallow_flag() {
789 let schema = serde_json::json!({
790 "type": "object",
791 "properties": { "url": { "type": "string" } },
792 "required": ["url"]
793 });
794 let d = describe_from_parts(
795 "spider__crawl",
796 "crawl",
797 "",
798 ToolKind::Builtin,
799 Some(&schema),
800 );
801 assert!(!d.shallow);
802 assert_eq!(d.kind, ToolKind::Builtin);
803 assert_eq!(d.args.len(), 1);
804 assert_eq!(d.parameters.as_ref(), Some(&schema));
805 // No schema → shallow, no args.
806 let bare = describe_from_parts("foo__bar", "bar", "", ToolKind::Mcp, None);
807 assert!(bare.shallow);
808 assert!(bare.args.is_empty());
809 }
810
811 #[tokio::test]
812 async fn run_search_filters_builtins_by_kind_but_appends_composio() {
813 // `kind = Composio`: built-ins filtered out, the caller-fetched Composio
814 // candidates (searchable-not-listed) still appear.
815 let builtins = vec![
816 desc("foo__search", "search", "search the web", ToolKind::Mcp),
817 desc("bar__do", "do", "do a thing", ToolKind::Builtin),
818 ];
819 let composio = vec![desc("composio__slack", "Slack", "send", ToolKind::Composio)];
820 let out = run_search(
821 "search",
822 builtins,
823 composio,
824 Some(ToolKind::Composio),
825 25,
826 ToolRanker::Bm25,
827 None,
828 )
829 .await;
830 assert!(out.iter().all(|d| d.kind == ToolKind::Composio));
831 assert!(out.iter().any(|d| d.id == "composio__slack"));
832
833 // `kind = None`: everything is ranked; no Composio unless the caller
834 // passed candidates (mirrors Core's key-gated fetch — empty here).
835 let builtins = vec![desc("foo__search", "search", "the web", ToolKind::Mcp)];
836 let out = run_search(
837 "search",
838 builtins,
839 Vec::new(),
840 None,
841 25,
842 ToolRanker::Bm25,
843 None,
844 )
845 .await;
846 assert_eq!(out.len(), 1);
847 assert!(out.iter().all(|d| d.kind != ToolKind::Composio));
848 }
849}