Skip to main content

robonix_pilot/
discovery.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// Author: wheatfox <wheatfox17@icloud.com>
3
4use anyhow::Result;
5use robonix_atlas::client::AtlasClient;
6use robonix_atlas::pb as atlas_pb;
7use robonix_scribe::warn;
8use std::collections::HashSet;
9
10/// LLM-facing tool name = `<area>_<leaf>` of a contract_id, where
11/// `<area>` is the segment immediately before the leaf.
12/// Examples:
13///   `robonix/primitive/camera/snapshot`        → `camera_snapshot`
14///   `robonix/primitive/lidar/snapshot`         → `lidar_snapshot`
15///   `robonix/primitive/chassis/move`           → `chassis_move`
16///   `robonix/service/memory/search`             → `memory_search`
17///   `robonix/service/navigation/navigate`      → `navigation_navigate`
18///
19/// Plain leaf-only used to be enough but multiple providers share leaves
20/// (`snapshot` on camera AND lidar). The OpenAI tool-list collapses
21/// duplicates and the LLM picks the wrong one. Prefixing with the
22/// area segment disambiguates while staying short and human-readable.
23///
24/// Executor still routes via the *full* `contract_id` (the leaf is
25/// the MCP-server-side tool name, which is unique within a single
26/// driver's FastMCP server). This function only renames at the
27/// LLM-↔-pilot boundary.
28pub fn llm_name(contract_id: &str) -> String {
29    let mut segs = contract_id.rsplit('/');
30    let leaf = segs.next().unwrap_or(contract_id);
31    let area = segs.next().unwrap_or("");
32    if area.is_empty() {
33        leaf.to_string()
34    } else {
35        format!("{area}_{leaf}")
36    }
37}
38
39/// One row per provider that registered a CAPABILITY.md, summarised for the
40/// LLM-facing "## Capability docs" block in pilot's system prompt. We expose
41/// the `provider_id` (what the LLM passes to `read_capability_doc`), the
42/// package `kind` (from atlas's authoritative `CapabilityProvider.kind`, so
43/// skills can be flagged read-first), and a one-line `description` lifted from
44/// the CAPABILITY.md frontmatter — enough for the model to judge relevance
45/// without reading the full manual. The internal `namespace` and any
46/// filesystem path are deliberately NOT exposed.
47pub struct CapDoc {
48    pub provider_id: String,
49    pub kind: String,
50    pub description: String,
51}
52
53/// Pull `description` from a CAPABILITY.md YAML frontmatter block.
54///
55/// The package-level frontmatter is a leading `---` … `---` fence with a single
56/// `description: <one line>` key (see the CAPABILITY.md format spec). The
57/// provider *kind* is deliberately NOT read here — it comes from atlas's
58/// authoritative `CapabilityProvider.kind` (set at registration via
59/// `Primitive`/`Service`/`Skill`), so the hand-written markdown can never drift
60/// from it. Returns an empty string when there is no frontmatter or no
61/// `description:` key, which is non-fatal: the provider still appears in the
62/// index, just without a one-line description until its CAPABILITY.md is updated.
63fn parse_description(md: &str) -> String {
64    let t = md.trim_start();
65    let Some(rest) = t.strip_prefix("---") else {
66        return String::new();
67    };
68    let Some(end) = rest.find("\n---") else {
69        return String::new();
70    };
71    for line in rest[..end].lines() {
72        if let Some(v) = line.trim().strip_prefix("description:") {
73            return v.trim().trim_matches('"').to_string();
74        }
75    }
76    String::new()
77}
78
79/// Map atlas's `CapabilityProvider.kind` enum to the lowercase label the prompt
80/// uses. Atlas is the source of truth for a provider's kind.
81fn kind_label(kind: i32) -> String {
82    match atlas_pb::Kind::try_from(kind) {
83        Ok(atlas_pb::Kind::Primitive) => "primitive",
84        Ok(atlas_pb::Kind::Service) => "service",
85        Ok(atlas_pb::Kind::Skill) => "skill",
86        _ => "",
87    }
88    .to_string()
89}
90
91/// Returns a `CapDoc` per provider that registered non-empty CAPABILITY.md
92/// *content*. Pilot lists these in the system prompt and instructs the LLM to
93/// pull the full text on demand via the `read_capability_doc` builtin.
94pub async fn cap_md_index(atlas: &mut AtlasClient) -> Result<Vec<CapDoc>> {
95    let providers = atlas
96        .query_capabilities("", "", atlas_pb::Transport::Unspecified)
97        .await?;
98    let mut out = Vec::new();
99    for provider in providers {
100        if provider.capability_md.trim().is_empty() {
101            continue;
102        }
103        let kind = kind_label(provider.kind);
104        let description = parse_description(&provider.capability_md);
105        out.push(CapDoc {
106            provider_id: provider.id,
107            kind,
108            description,
109        });
110    }
111    Ok(out)
112}
113
114/// Query atlas for every MCP-transport capability. Returns one
115/// `(provider_id, Capability)` pair per usable MCP contract; callers
116/// pull description + input_schema_json out of `params.kind` themselves.
117/// Capabilities with missing or non-MCP params are dropped with a warning.
118pub async fn discover(atlas: &mut AtlasClient) -> Result<Vec<(String, atlas_pb::Capability)>> {
119    let providers = atlas
120        .query_capabilities("", "", atlas_pb::Transport::Mcp)
121        .await?;
122
123    let mut out = Vec::new();
124    for provider in providers {
125        for cap in provider.capabilities {
126            if cap.transport != atlas_pb::Transport::Mcp as i32 {
127                continue;
128            }
129            // Sanity: an MCP capability without McpParams is malformed —
130            // skip rather than feed garbage to the LLM.
131            let has_mcp = matches!(
132                cap.params.as_ref().and_then(|p| p.kind.as_ref()),
133                Some(atlas_pb::transport_params::Kind::Mcp(_))
134            );
135            if !has_mcp {
136                warn!(
137                    "[pilot/discovery] provider='{}' contract='{}' has no MCP params; skipping",
138                    provider.id, cap.contract_id
139                );
140                continue;
141            }
142            out.push((provider.id.clone(), cap));
143        }
144    }
145    Ok(out)
146}
147
148/// Load the immutable contract-level exclusions for Pilot's model catalog.
149/// A missing field means visible so Pilot remains compatible with an older
150/// Atlas. The capabilities remain registered and callable by other consumers.
151pub async fn non_llm_callable_contract_ids(atlas: &mut AtlasClient) -> Result<HashSet<String>> {
152    let contracts = atlas.list_contracts("").await?;
153    Ok(contracts
154        .into_iter()
155        .filter(|contract| contract.llm_callable == Some(false))
156        .map(|contract| contract.id)
157        .collect())
158}