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