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}