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;
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}