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;
7
8/// LLM-facing tool name = `<area>_<leaf>` of a contract_id, where
9/// `<area>` is the segment immediately before the leaf.
10/// Examples:
11/// `robonix/primitive/camera/snapshot` → `camera_snapshot`
12/// `robonix/primitive/lidar/snapshot` → `lidar_snapshot`
13/// `robonix/primitive/chassis/move` → `chassis_move`
14/// `robonix/service/memory/search` → `memory_search`
15/// `robonix/service/navigation/navigate` → `navigation_navigate`
16///
17/// Plain leaf-only used to be enough but multiple providers share leaves
18/// (`snapshot` on camera AND lidar). The OpenAI tool-list collapses
19/// duplicates and the LLM picks the wrong one. Prefixing with the
20/// area segment disambiguates while staying short and human-readable.
21///
22/// Executor still routes via the *full* `contract_id` (the leaf is
23/// the MCP-server-side tool name, which is unique within a single
24/// driver's FastMCP server). This function only renames at the
25/// LLM-↔-pilot boundary.
26pub fn llm_name(contract_id: &str) -> String {
27 let mut segs = contract_id.rsplit('/');
28 let leaf = segs.next().unwrap_or(contract_id);
29 let area = segs.next().unwrap_or("");
30 if area.is_empty() {
31 leaf.to_string()
32 } else {
33 format!("{area}_{leaf}")
34 }
35}
36
37/// One row per registered capability, summarised for the LLM-facing
38/// "## Capability docs (lazy-load via read_file)" block in pilot's
39/// system prompt. Includes only providers that registered with a non-empty
40/// `capability_md_path`. The path is what we hand the LLM verbatim;
41/// the executor's `read_file` builtin resolves it (it must be readable
42/// from the executor's host workspace).
43pub struct CapDoc {
44 pub provider_id: String,
45 pub namespace: String,
46 pub md_path: String,
47}
48
49/// Returns a `CapDoc` per capability that has a non-empty
50/// `capability_md_path`. Pilot lists these in the system prompt and
51/// the LLM read_files them lazily.
52pub async fn cap_md_index(atlas: &mut AtlasClient) -> Result<Vec<CapDoc>> {
53 let providers = atlas
54 .query_capabilities("", "", atlas_pb::Transport::Unspecified)
55 .await?;
56 let mut out = Vec::new();
57 for provider in providers {
58 if provider.capability_md_path.is_empty() {
59 continue;
60 }
61 out.push(CapDoc {
62 provider_id: provider.id,
63 namespace: provider.namespace,
64 md_path: provider.capability_md_path,
65 });
66 }
67 Ok(out)
68}
69
70/// Query atlas for every MCP-transport capability. Returns one
71/// `(provider_id, Capability)` pair per LLM-callable contract; callers
72/// pull description + input_schema_json out of `params.kind` themselves.
73/// Capabilities with missing or non-MCP params are dropped with a warning.
74pub async fn discover(atlas: &mut AtlasClient) -> Result<Vec<(String, atlas_pb::Capability)>> {
75 let providers = atlas
76 .query_capabilities("", "", atlas_pb::Transport::Mcp)
77 .await?;
78
79 let mut out = Vec::new();
80 for provider in providers {
81 for cap in provider.capabilities {
82 if cap.transport != atlas_pb::Transport::Mcp as i32 {
83 continue;
84 }
85 // Sanity: an MCP capability without McpParams is malformed —
86 // skip rather than feed garbage to the LLM.
87 let has_mcp = matches!(
88 cap.params.as_ref().and_then(|p| p.kind.as_ref()),
89 Some(atlas_pb::transport_params::Kind::Mcp(_))
90 );
91 if !has_mcp {
92 log::warn!(
93 "[pilot/discovery] provider='{}' contract='{}' has no MCP params; skipping",
94 provider.id,
95 cap.contract_id
96 );
97 continue;
98 }
99 out.push((provider.id.clone(), cap));
100 }
101 }
102 Ok(out)
103}