Skip to main content

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}