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}